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().

Dedicated servers only. The integrated server that runs inside singleplayer never loads plugins — this API exists only on the standalone server (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.

Building from source instead? 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:

  1. A plugin.yml at 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.

  1. The main class extending net.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/.

Lifecycle gotcha: 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.

Command executors run on the sender's connection thread. Keep them fast — heavy work belongs on your own thread or a scheduled task (see Scheduler & threading).

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 eventsCKey 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 eventsCKey accessors
BlockBreakEvent✓ getPlayer(), getX/Y/Z(), getPreviousState()
BlockPlaceEvent✓ getPlayer(), getX/Y/Z(), getPreviousState(), getNewState(), setNewState(long)
Item eventsCKey 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 eventsCKey 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 eventsCKey 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

MethodWhat 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 methodsWhat 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.

ControlEffect
IOpen / close the inventory screen
Left clickPick up / place the whole stack, merge, or swap
Right clickPick up half, or place one item
Shift clickMove the stack between container and player inventory
QDrop one item (hovered slot, or main hand in-world)
Ctrl+QDrop 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.

Protocol note (v10): window 0 is the player inventory (slots 0–N–1); a container window W is container slots 0–M–1 followed by player storage M–M+N–1. Hands and the cursor live at reserved slot ids. Clicks are requests — the server owns every stack and answers with 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);
  • setBlock broadcasts the change to every client and fires a BlockPlaceEvent — 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 with getBlock instead.

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
CallWhat 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

CodeRuns on
Player / block / chat / command eventsthe connection's thread
LiquidFlowEvent, chunk eventsthe sim / chunk threads
Scheduler tasksthe 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 callRendersEvents
label(text) / label(id, text) Static text—
button(id, text) Clickable buttonclick
textField(id, placeholder, initial) Text inputtext (current content)
toggle(id, label, initial) On/off switchtoggle ("true"/"false")
choice(id, label, selected, options...) Cycling option pickerchange (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().

ActionSent whenValue
clickButton or clickable image pressed—
textText field content (flushed on interaction)current text
toggleToggle flipped"true" / "false"
changeChoice cyclednew option
closeWhole 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.
Threading: 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:

slotIdBinds 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

  1. mvn package your plugin.
  2. Drop the jar from target/ into the plugins/ directory next to the server jar (the server creates it on first run).
  3. Restart the server. You should see:
[plugins] enabled MyPlugin (myplugin-1.0.jar)
[plugins] 1 plugin(s) loaded

Troubleshooting

SymptomCause
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