Plugin development
Extend your Open-Voxel dedicated server with Java plugins: register commands, listen to events, change blocks, spawn entities, schedule tasks — all through a small Bukkit-style API.
#Overview
A plugin is a single .jar you drop into the server's plugins/
directory. On startup the server scans that directory, loads each jar with its own
class loader, reads its plugin.properties, and calls
onEnable() on your main class. On shutdown it unregisters your handlers,
commands and tasks, then calls onDisable().
Everything a plugin can do is reachable through the static
VoxelServer facade (the front door, like Bukkit's Bukkit
class) or the ServerInterface handle available inside your plugin via
getServer().
openvoxel-server).#Project setup
You need Java 11+ and Maven. Create a plain Maven project for your plugin,
add the Open-Voxel snapshot repository, and depend on the server artifact with
provided scope — the classes are present on the server at runtime, they
just aren't yours to ship:
<repositories>
<repository>
<id>openvoxel-snapshots</id>
<url>http://network.rushhosting.net:30006/snapshots</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>net.openvoxel</groupId>
<artifactId>openvoxel-server</artifactId>
<version>0.0.8-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
</dependencies>
The engine artifact (net.openvoxel:openvoxel-engine) resolves from the
same repository transitively — that's where Entity and friends live.
mvn install in the
game repo, then the server repo, installs the same artifacts into your local
.m2 — no <repositories> block needed.#Anatomy of a plugin
Two things make a jar a plugin:
- A
plugin.ymlat the root of the jar:
name: MyPlugin # required
main: com.example.myplugin.MyPlugin # required (Plugin subclass)
version: 1.0 # default "1.0"
description: What it does
author: you # or authors: [you, them]
depend: [OtherPlugin] # hard deps: load after them, refuse without
softdepend: [MaybeThere] # order-only: load after if present
A legacy plugin.properties (name= / main=)
still loads with all other fields defaulted.
- The
mainclass extendingnet.openvoxel.server.plugin.Plugin:
package com.example.myplugin;
import net.openvoxel.server.plugin.Plugin;
public class MyPlugin extends Plugin {
@Override public void onEnable() {
// register commands, events, tasks here
}
@Override public void onDisable() {
// save state; handlers/commands/tasks are already gone
}
}
With Maven, put plugin.properties in src/main/resources
and mvn package — the jar lands in target/.
getServer() returns
null until onEnable(). In constructors, field initializers
and static contexts use the static VoxelServer facade instead — it's
wired before any plugin loads.#Commands
Chat lines starting with / are routed to the command registry instead
of chat. Register yours in onEnable():
VoxelServer.getCommandManager().register("heal", "Heal yourself",
(sender, args) -> {
PlayerEntity e = sender.getEntity();
if (e != null) e.health = 20f;
sender.sendMessage("Healed!");
}, this);
label— the command name (case-insensitive, no leading slash).description— shown by/help-style listings (describeAll()).executor—(Player sender, String[] args); args are the whitespace-split words after the label.owner— your plugin instance, so the command is unregistered when the plugin is disabled.
Registering an existing label replaces it (a warning is logged) — that's how
you override built-ins. Unknown commands answer with
"Unknown command: /…" automatically, and an executor that throws is
caught, logged, and reported to the sender.
Need the raw line before dispatch — e.g. to block a command or add tab-less
aliases? Listen to PlayerCommandPreprocessEvent (cancellable, message
rewritable).
#Events
Implement the (marker) interface
net.openvoxel.server.plugin.event.Listener on any class, annotate
handler methods with @EventHandler, and register once:
public class MyPlugin extends Plugin implements Listener {
@Override public void onEnable() {
VoxelServer.getPluginManager().registerEvents(this, this);
}
@EventHandler
public void onJoin(PlayerJoinEvent e) {
VoxelServer.broadcast(e.getPlayer().getName() + " joined!");
}
}
A handler must be public and take exactly one parameter: a subclass of
Event.
Priorities
Handlers run in EventPriority order:
LOWEST → LOW → NORMAL → HIGH → HIGHEST → MONITOR. Lower runs first, so
HIGH/HIGHEST see the result of earlier handlers' changes.
MONITOR runs last, observes the final outcome (including cancellation)
and must not modify the event.
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onPlace(BlockPlaceEvent e) { /* … */ }
Cancelling
Events implementing Cancellable can be vetoed with
setCancelled(true) — a cancelled BlockPlaceEvent leaves the
world unchanged, a cancelled PlayerChatEvent swallows the message, a
cancelled PlayerJoinEvent kicks the joining player (set
setKickReason first). Handlers marked
ignoreCancelled = true are skipped once an event is cancelled;
MONITOR handlers always run.
Event reference
Every event the server fires, grouped by package. C = cancellable.
| Player events | C | Key accessors |
|---|---|---|
PlayerJoinEvent | ✓ | getPlayer(), getKickReason(), setKickReason(String) |
PlayerQuitEvent | — | getPlayer() |
PlayerKickEvent | — | getPlayer(), getReason() |
PlayerChatEvent | ✓ | getMessage(), setMessage(String) |
PlayerCommandPreprocessEvent | ✓ | getMessage(), setMessage(String) — fires before command dispatch |
PlayerMoveEvent | ✓ | getFrom(), getTo() (Locations) |
PlayerTeleportEvent | ✓ | extends PlayerMoveEvent; setTo(Location) can retarget |
PlayerInteractEvent | ✓ | getAction() (LEFT_CLICK_AIR, LEFT_CLICK_BLOCK,
RIGHT_CLICK_BLOCK), getBlockX/Y/Z() |
PlayerAnimationEvent | — | arm-swing / interact animation frames |
PlayerToggleSneakEvent | ✓ | isSneaking() |
PlayerToggleSprintEvent | ✓ | isSprinting() |
PlayerKeyPressEvent | — | getKey() — the Key enum
(e.getKey() == Key.Y; no LWJGL needed);
in-world press edges only, never while a GUI is capturing
typing |
PlayerRespawnEvent | — | getRespawnLocation(), setRespawnLocation(Location) |
PlayerDeathEvent | — | getPlayer() (extends EntityDeathEvent) |
| Block events | C | Key accessors |
|---|---|---|
BlockBreakEvent | ✓ | getPlayer(), getX/Y/Z(), getPreviousState() |
BlockPlaceEvent | ✓ | getPlayer(), getX/Y/Z(), getPreviousState(),
getNewState(), setNewState(long) |
| Item events | C | Key accessors |
|---|---|---|
ItemDropEvent | ✓ | getPlayer(), getStack() — a cancelled drop
keeps the stack in its slot |
ItemPickupEvent | ✓ | getPlayer(), getItem() (the ItemEntity),
getStack() |
InventoryClickEvent | ✓ | getPlayer(), getWindowId(), getSlotId(),
getAction() (left / right / shift / drop) |
ContainerOpenEvent | ✓ | getPlayer(), getX/Y/Z() (the container block) |
ContainerCloseEvent | — | getPlayer(), getWindowId() |
| Entity events | C | Key accessors |
|---|---|---|
EntitySpawnEvent | ✓ | getEntity(), getEntityId() |
EntityDespawnEvent | — | getEntity(), getEntityId() |
EntityDamageEvent | ✓ | getCause() (LIQUID, PLUGIN),
getAmount(), setAmount(float) — 2 = one heart |
EntityRegainHealthEvent | ✓ | getReason() (REGEN, CUSTOM),
getAmount(), setAmount(int) |
EntityDeathEvent | — | getEntity() |
EntityMountEvent | ✓ | getPassenger(), getVehicle() |
EntityDismountEvent | — | getPassenger(), getVehicle() |
| World events | C | Key accessors |
|---|---|---|
ChunkLoadEvent | — | getChunkX(), getChunkZ() |
ChunkUnloadEvent | — | getChunkX(), getChunkZ() |
LiquidFlowEvent | ✓ | getX/Y/Z(), getPreviousState(), getNewState(),
isDecay(), getNewLevel() |
#Players
A Player is a connected account: fixed identity, live world state read
from its entity. You get instances from events, command senders, and lookups:
List<Player> all = VoxelServer.getOnlinePlayers(); // snapshot
Player one = VoxelServer.getPlayer("wildmaster84"); // or null
Useful methods
| Method | What it does |
|---|---|
sendMessage(String) | Chat line to just this player |
getName() / getUuid() / getAddress() |
Identity (uuid is hex without dashes) |
getLocation() |
Live position + facing (copy — mutating it does nothing) |
setLocation(Location) |
Teleport: fires PlayerTeleportEvent (cancellable/retargetable),
moves the entity, force-pushes the client |
getX() / getY() / getZ() | Feet coordinates |
getHealth() | Hearts, 0–20 |
isDead() | True while the death screen is up |
getEntity() |
The PlayerEntity (null until the join completes) |
| Inventory methods | What it does |
|---|---|
getInventory() |
The 50-slot storage Inventory (engine class) |
getMainHand() / setMainHand(ItemStack) |
The held stack — aliases hotbar slot 1 (player:40) |
getOffHand() / setOffHand(ItemStack) |
The off-hand stack |
giveItem(ItemStack) |
Adds to the inventory; overflow drops at the player's feet |
// Teleport a player 10 blocks up
Location loc = player.getLocation().add(0, 10, 0);
player.setLocation(loc);
#Items & inventory
Items are NBT-driven: an ItemStack is a
Material, a count, and a free-form NBT compound (custom names, lore,
tint, plugin data). A Material defines the static parts —
name, model/texture, max stack size, container tags, and a behavior
(place_block, place_entity,
open_inventory). Custom materials are registered in server
code — ServerItems.registerAll() with a fluent builder
(item("book").texture(...).maxStack(16).tags("book").register())
— and pushed to clients at join (ITEM_REGISTRY, protocol
v12), so the server is the single holder of the item definitions.
Every registered block type implicitly has a
place-block item named block:<name>. Build stacks with
ItemFactory:
Plugin items with assets. From
onEnable(), call registerItem(name) on your
plugin class; texture/model paths are resources inside your plugin
jar — the server namespaces them (plugins/<plugin>/...)
and pushes the bytes to clients at join (ASSET, protocol
v14), where they're cached on disk and resolved before classpath
resources:
// in onEnable(); assets/sword.png ships inside the plugin jar
registerItem(\"sword\")
.texture(\"assets/sword.png\")
.maxStack(1)
.register();
// engine classes, available via the transitive openvoxel-engine dep
ItemStack book = ItemFactory.create("book").count(3)
.name("Journal").lore("Property of spawn").build();
ItemStack stone = ItemFactory.create("block:stone").count(64).build();
player.giveItem(book); // overflow drops at the player's feet
The player inventory
50 storage slots: a 10×4 main grid (slots 0–39) plus a
10-slot hotbar row (slots 40–49). The main hand is hotbar slot 1
(PlayerEntity.MAIN_HAND_SLOT = 40) — MC-style: the hotbar row
is the equipment row, so whatever sits in that slot is what the player builds
and breaks with. The off hand is a separate stack. There are no armor slots;
appearance belongs to the model system.
| Control | Effect |
|---|---|
I | Open / close the inventory screen |
| Left click | Pick up / place the whole stack, merge, or swap |
| Right click | Pick up half, or place one item |
| Shift click | Move the stack between container and player inventory |
Q | Drop one item (hovered slot, or main hand in-world) |
Ctrl+Q | Drop the whole stack |
While a stack rides the cursor it's part of the window state and syncs like everything else — closing a container returns it to the inventory.
The built-in /give <material> [count] command
(/give book 3, /give block:stone 64, bare block names
work too) is the quickest way to test item plugins in-game.
Containers
Container blocks (chests, bookshelves, …) are block entities with their
own tag-filtered inventories. Right-clicking one opens a container window:
the top half mirrors the container, the bottom half is the player's inventory,
and shift-clicking moves stacks between the two. Filters work both ways —
a bookshelf accepts only book-tagged items in, and a filtered
container decides what may leave. All movement is server-authoritative: the
client asks, the server answers with a corrective sync.
Drops
Breaking a block spawns an ItemEntity — a physical drop with
velocity and gravity. Drops thrown by players follow the camera, nearby stacks
of the same material merge, and drops despawn after 5 minutes. Players pick
them up by walking over them (after a short pickup delay). Drop spawns go
through EntitySpawnEvent, so a plugin veto leaves the source slot
untouched.
Persistence
Player inventories, hands, container contents and live drops all save with the world and reload on restart. Save loading is resize-tolerant: if a UI redesign changes the inventory size, growing keeps every saved stack (new cells start empty) and shrinking drops the overflow with a log line.
INVENTORY_SYNC.#Blocks & chunks
The world is addressed by integer cell coordinates; blocks are packed 64-bit states (type + properties). The plugin API works with type names and default properties:
// Read
long state = VoxelServer.getBlock(x, y, z);
String name = VoxelServer.blockName(state); // "stone", "water", … (null = air)
// Write
long sand = VoxelServer.blockState("sand"); // -1 if the name is unknown
if (sand != -1) VoxelServer.setBlock(x, y, z, sand);
setBlockbroadcasts the change to every client and fires aBlockPlaceEvent— a plugin cancelling that event prevents your edit too.blockState(name)gives the type's default properties. To preserve or tweak orientation/variant bits, start from the block you read withgetBlockinstead.
Building shapes
// A 5x5 stone platform under the player
long stone = VoxelServer.blockState("stone");
int px = player.getLocation().getBlockX();
int py = player.getLocation().getBlockY() - 1;
int pz = player.getLocation().getBlockZ();
for (int dx = -2; dx <= 2; dx++)
for (int dz = -2; dz <= 2; dz++)
VoxelServer.setBlock(px + dx, py, pz + dz, stone);
Chunks
The world is split into vertical chunk columns of 16×16 blocks, 512 blocks tall. Block coordinates convert with floor-division:
int chunkX = Math.floorDiv(blockX, 16);
int chunkZ = Math.floorDiv(blockZ, 16);
ChunkLoadEvent/ChunkUnloadEvent tell you when columns
enter or leave the loaded set (they fire on the chunk threads — see threading).
There's no bulk "set chunk" call: you modify the world one
setBlock at a time, and chunk events are how you know where work
is happening — e.g. decorating freshly generated terrain:
@EventHandler
public void onChunkLoad(ChunkLoadEvent e) {
// sprinkle a flower at the center of every 64th chunk
if ((e.getChunkX() * 31 + e.getChunkZ()) % 64 != 0) return;
int bx = e.getChunkX() * 16 + 8, bz = e.getChunkZ() * 16 + 8;
// find the surface, then place — schedule it (chunk thread!)
VoxelServer.getScheduler().runLater(this, () -> {
for (int y = 320; y > 0; y--) {
if (VoxelServer.blockName(VoxelServer.getBlock(bx, y, bz)) != null) {
long flower = VoxelServer.blockState("poppy");
if (flower != -1) VoxelServer.setBlock(bx, y + 1, bz, flower);
break;
}
}
}, 1);
}
Liquids
Water and lava flow server-side at 20 Hz. Every cell change fires
LiquidFlowEvent — cancellable, so you can freeze liquids in a region or
stop lava damage at the source:
@EventHandler(ignoreCancelled = true)
public void onFlow(LiquidFlowEvent e) {
if (insideSpawn(e.getX(), e.getY(), e.getZ())) e.setCancelled(true);
}
#Entities
Players are entities; the other kind plugins can create directly is the falling block — it spawns as an entity, falls with physics, and lands as a solid block:
Entity anvil = VoxelServer.spawnFallingBlock(x, y + 20, z,
VoxelServer.blockState("sand"));
// null when the spawn was cancelled or out of range
| Call | What it does |
|---|---|
getEntity(int id) |
Live entity by network id (from EntitySpawnEvent or
entity.entityId) |
spawnFallingBlock(x, y, z, state) |
Spawns a physics block; fires EntitySpawnEvent |
damage(entity, amount, cause) |
Damage routed through EntityDamageEvent; amount 2 = one heart;
lethal damage fires death events |
Damage causes are EntityDamageEvent.Cause.LIQUID (damaging liquids)
and PLUGIN (your own damage() calls) — check the cause in a
handler to make regions safe:
@EventHandler(ignoreCancelled = true)
public void onDamage(EntityDamageEvent e) {
if (e.getCause() == EntityDamageEvent.Cause.LIQUID
&& inSafeZone(e.getEntity()))
e.setCancelled(true);
}
#Riding & sitting
Any entity can carry a passenger. While riding, the passenger is locked to the vehicle's seat, movement input is suspended, everyone sees the riding pose, and pressing sneak dismounts:
// Let a player ride a falling block (yes, really)
Entity seat = VoxelServer.spawnFallingBlock(x, y + 1, z,
VoxelServer.blockState("sand"));
if (seat != null) player.startRiding(seat); // fires EntityMountEvent
player.stopRiding(); // fires EntityDismountEvent
player.isInsideVehicle(); // true while riding
Sitting needs no vehicle — furniture, the ground. Sitting players are frozen in place and show a sitting pose (distinct from riding); sneak stands up, and sitting while riding first dismounts:
player.setSitting(true); // sit where they stand
player.isSitting(); // false while riding (that's a vehicle)
The generic VoxelServer.mount(passenger, vehicle),
dismount(entity) and setSitting(entity, flag) forms do the
same for any entity pair.
#Scheduler & threading
The scheduler runs tasks on the server sim thread at 20 ticks per second (1 tick = 50 ms), so scheduled code can touch the world, entities and players without synchronization:
// once, 5 seconds from now
VoxelServer.getScheduler().runLater(this, () ->
VoxelServer.broadcast("5 seconds passed"), 100);
// every 30 seconds, starting after 10
int taskId = VoxelServer.getScheduler().runTimer(this, () -> {
for (Player p : VoxelServer.getOnlinePlayers())
p.sendMessage("Half a minute!");
}, 200, 600);
VoxelServer.getScheduler().cancel(taskId);
A plugin's tasks are cancelled automatically when it is disabled, and a throwing task is logged and cancelled (it can't kill the sim loop).
Threading rules
| Code | Runs on |
|---|---|
| Player / block / chat / command events | the connection's thread |
LiquidFlowEvent, chunk events | the sim / chunk threads |
| Scheduler tasks | the sim thread (world-safe) |
- Keep every handler fast — a slow connection-thread handler stalls that player's packets.
- World/entity mutation is for the sim thread: from other threads, hop
over with
runLater(..., 1). - Chat and player lookups (
broadcast,sendMessage,getOnlinePlayers) are safe from any thread. - Do heavy work (file I/O, HTTP) on your own thread — there is no async scheduler by design.
#Server-driven UI
Plugins can open rich, fully custom interfaces on the client — menus,
status panels, terminals, whole windowed desktops — declared on the server and
rendered by the vanilla client. The model is declarative: you describe elements,
the client draws them and reports interaction back as
PlayerUiEvents. No client mod, no resource pack.
Ui ui = Ui.builder(this, "Reactor")
.label("Core status")
.progress("heat", "Heat", 0.81f)
.toggle("coolant", "Coolant", true)
.choice("mode", "Mode", 0, "Eco", "Normal", "Overdrive")
.button("scram", "SCRAM")
.build();
player.openUi(ui);
Ownership
A player sees one server UI at a time. Reopening from the same plugin
replaces the current UI seamlessly; another plugin's openUi replaces
it too, and your plugin gets a close event for the UI it lost.
Interaction events are delivered only to the UI's holder plugin — other
plugins can't snoop or hijack it. player.closeUi() closes
programmatically.
Elements
| Builder call | Renders | Events |
|---|---|---|
label(text) / label(id, text) |
Static text | — |
button(id, text) |
Clickable button | click |
textField(id, placeholder, initial) |
Text input | text (current content) |
toggle(id, label, initial) |
On/off switch | toggle ("true"/"false") |
choice(id, label, selected, options...) |
Cycling option picker | change (new option) |
progress(id, label, fraction) |
Progress bar, 0.0–1.0 | — |
separator() |
Horizontal rule | — |
spacer() |
Vertical gap (flow layout) | — |
rect(id, colorHex) |
Solid color block (#RRGGBB/#RRGGBBAA) |
— |
image(assetId) |
Decorative PNG (see assets) | — |
image(id, assetId) |
Clickable PNG (icon) | click |
window(id, title) |
Draggable window (below) | close |
Layout
Elements stack vertically by default (flow). Calling
.at(x, y, w, h) after an element positions it absolutely, in
percent of the panel — so .at(0, 94, 100, 6) is a taskbar strip
along the bottom of any screen. windowSize(wPct, hPct) sets the
panel size as a percent of the screen (default 50×60).
Image assets
Declare PNGs with .asset(id, bytes) — they're shipped to the
client before the UI opens — and place them with image(...). Bundle
them in your jar and load with getResourceBytes:
.asset("wallpaper", getResourceBytes("icons/wallpaper.png"))
.asset("icon.term", getResourceBytes("icons/terminal.png"))
.image("wallpaper").at(0, 0, 100, 100) // full-panel background
.image("app.term", "icon.term").at(8, 8, 10, 14) // clickable icon
Live updates
Repaint individual elements without reopening the UI:
player.updateUi("heat", null, "0.92"); // move the bar
player.updateUi("status", "MELTDOWN", null); // retext a label
player.updateUi("wall", null, "wallpaper_dark"); // swap an image's asset
The meaning of the text/value arguments follows the
element type (label/button → text; textField → value replaces content;
toggle → value "true"/"false"; choice → value selects the option;
progress → value "0"–"1"; rect → value recolors; image → value swaps the
asset). A null argument leaves that field untouched, and updating
an id that isn't currently visible is harmlessly dropped — you don't have to
track what the player is looking at.
UI events
Everything arrives as a PlayerUiEvent (only to the holder
plugin): getElementId(), getAction(),
getValue(), plus getUi()/getUiId().
| Action | Sent when | Value |
|---|---|---|
click | Button or clickable image pressed | — |
text | Text field content (flushed on interaction) | current text |
toggle | Toggle flipped | "true" / "false" |
change | Choice cycled | new option |
close | Whole UI closed (ESC / closeUi / replaced):
element id is empty. A window's [x]: element id is the window id and the
value is "window" — the UI itself stays open. | see left |
Draggable windows
Group elements into draggable, overlapping windows — Crayfish-device-style desktops, multi-panel tools, floating terminals. The client owns all the chrome (title bar, drag, z-order, close box); you only declare groups:
Ui ui = Ui.builder(this, "OpenOS")
.windowSize(80, 80)
.image("wallpaper").at(0, 0, 100, 100) // root layer = desktop
.window("term", "Terminal").at(12, 10, 55, 62) // declares the window
.rect("", "#0C0E10F2").at(0, 0, 100, 100).inWindow("term")
.label("out0", "ready").at(4, 4, 92, 8).inWindow("term")
.textField("cmd", "type a command...", "")
.at(4, 84, 70, 11).inWindow("term")
.build();
window(id, title)declares a window; its.at()is the initial rect in percent of the panel (default 15, 15, 60, 60).inWindow(id)tags the previous element. Windowed elements lay out in percent of the window's content area and move with it.- Dragging, raise-on-click and the [x] button are client-side presentation.
[x] hides the window instantly and sends
(windowId, "close", "window")so you can drop it from your model. - Window positions persist for the connection — close an app and reopen it, it reappears where the user left it.
- To open or close a window server-side, reopen the UI with (or without) its elements: window rects and dynamic state survive the replace, so it feels seamless.
openUi, updateUi
and closeUi are safe from any thread (including scheduler tasks).
PlayerUiEvent handlers run on the player's connection thread like
other player events — keep them fast.A complete pocket-computer example (desktop with wallpaper, taskbar clock,
clickable icons and three windowed apps) ships in the server repo at
examples/computer-plugin.
#UI Builder
The UI Builder is a standalone visual designer for game screens — it dogfoods the engine's own Screen/Widget system, so what you drag around is exactly what the game renders. It ships as a fat jar (engine + LWJGL bundled):
cd D:\github\Open-Voxel\builder
java -jar target\openvoxel-builder-0.0.8-Debug.jar
Launch it from the builder folder (or a shortcut whose
"Start in" points there) — projects live in ui-projects/
relative to the working directory. Projects save as
.uibuild.json and export compilable Java
Screen subclasses you drop into the client.
Export Plugin (server-driven UIs)
Export Plugin writes a second class, <Name>Ui,
next to the Screen export — paste it into your plugin. It builds the
same layout through the server-driven Ui API, so the client
needs nothing new (the UI rides the normal SERVER_UI channel):
// open it for a player (holder plugin receives all its events)
ServerListUi.open(this, player);
// interactions arrive as PlayerUiEvent with the builder widget ids;
// live-update with player.updateUi(id, newText, newValue)
The export flattens the widget tree to the server UI's element model:
root widgets and panel children become absolutely-positioned elements,
box containers become automatic vertical flow, panels become rects,
checkbox→toggle, slider→progress (read-only), images go through
Ui assets read from the plugin jar
(plugin.getResourceBytes(...)). Widget types with no
server-UI equivalent are skipped with a comment in the generated file.
Slot binding
Slot widgets carry design-time metadata (slotId) that binds the
exported screen to live inventories at runtime:
| slotId | Binds to |
|---|---|
player:0–player:39 |
Main storage grid (10×4) |
player:40–player:49 |
Hotbar row — player:40 is the main hand |
container:N |
Container slot N (chests & other block entities) |
main_hand / off_hand |
Hand slots |
The vanilla inventory layout (untitledInventory, 50 slots) and the
server list (server-list) ship as example projects.
#Complete example
A small spawn-protection plugin: greets players, adds /spawn and
/platform commands, and blocks breaking inside a 32-block radius.
package com.example.spawnprotect;
import net.openvoxel.server.plugin.*;
import net.openvoxel.server.plugin.event.*;
import net.openvoxel.server.plugin.event.block.BlockBreakEvent;
import net.openvoxel.server.plugin.event.player.PlayerJoinEvent;
public class SpawnProtect extends Plugin implements Listener {
private static final int RADIUS = 32;
@Override public void onEnable() {
VoxelServer.getPluginManager().registerEvents(this, this);
VoxelServer.getCommandManager().register("spawn", "Back to spawn",
(sender, args) -> {
Location l = sender.getLocation();
sender.setLocation(new Location(0.5, 80, 0.5,
l.getYaw(), l.getPitch()));
}, this);
VoxelServer.getCommandManager().register("platform", "Stone platform under you",
(sender, args) -> {
long stone = VoxelServer.blockState("stone");
int px = sender.getLocation().getBlockX();
int py = sender.getLocation().getBlockY() - 1;
int pz = sender.getLocation().getBlockZ();
for (int dx = -2; dx <= 2; dx++)
for (int dz = -2; dz <= 2; dz++)
VoxelServer.setBlock(px + dx, py, pz + dz, stone);
}, this);
}
@EventHandler
public void onJoin(PlayerJoinEvent e) {
e.getPlayer().sendMessage("Welcome! Spawn is protected (" + RADIUS + " blocks).");
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onBreak(BlockBreakEvent e) {
int dx = e.getX(), dz = e.getZ();
if (dx * dx + dz * dz <= RADIUS * RADIUS) {
e.setCancelled(true);
e.getPlayer().sendMessage("Spawn is protected.");
}
}
}
# plugin.yml (src/main/resources)
name: SpawnProtect
main: com.example.spawnprotect.SpawnProtect
#Deploying
mvn packageyour plugin.- Drop the jar from
target/into theplugins/directory next to the server jar (the server creates it on first run). - Restart the server. You should see:
[plugins] enabled MyPlugin (myplugin-1.0.jar)
[plugins] 1 plugin(s) loaded
Troubleshooting
| Symptom | Cause |
|---|---|
missing plugin.yml (need name: and main:) |
The file isn't at the jar root — it belongs in
src/main/resources |
| Jar silently skipped | name or main empty, or main isn't a
Plugin subclass |
NoClassDefFoundError on enable |
You compiled against a newer API than the server runs — rebuild against the server's version |
| Handler never fires | Missing registerEvents call, non-public method, or
the parameter isn't an Event subclass |
| Changes to the world don't appear | You called world methods off the sim thread — hop over with the scheduler |