Switchboard

Software, photography, music, and more.

PvPArea

Posted 06/09/2026 by Tim Northrop

I needed a Minecraft server plugin that enables keepInventory and keepLevel, but only within certain designated areas. I couldn't find one that did what I needed and nothing more, so I wrote it.

You can download the .jar from the plugin's Modrinth page to add it to your server (still under review at the time of this writing). Find the source code on the GitHub repository.

I started by defining a PvPArea class that represents a rectangular area in the overworld. Each area has a set of 4 integers representing the minimum and maximum x and z coordinates. I overwrote the equals and hashCode methods so that areas are decided to be equal purely based off of their coordinates.

public class PvPArea {
    private final int xMin, xMax, zMin, zMax;

    public PvPArea(int xMin, int xMax, int zMin, int zMax) {
        if (xMin >= xMax || zMin >= zMax) {
            throw new RuntimeException("Coordinate minimums must be less than maximums");
        }
        this.xMin = xMin;
        this.xMax = xMax;
        this.zMin = zMin;
        this.zMax = zMax;
    }

    public boolean hasPlayerWithin(Player player) {
        Location playerLoc = player.getLocation();
        int pX = playerLoc.getBlockX();
        int pZ = playerLoc.getBlockZ();

        return pX >= xMin && pX <= xMax && pZ >= zMin && pZ <= zMax;
    }

    public boolean overlapsArea(PvPArea other) {
        if (this.equals(other)) {
            return true;
        }
        return !(zMin >= other.getZMax() && zMax > other.getZMax()) &&
                !(zMin < other.getZMin() && zMax <= other.getZMin()) &&
                !(xMin >= other.getXMax() && xMax > other.getXMax()) &&
                !(xMin < other.getXMin() && xMax <= other.getXMin());
    }

    public boolean overlapsAnyArea(Set<PvPArea> others) {
        for (PvPArea other : others) {
            if (this.overlapsArea(other)) {
                return true;
            }
        }
        return false;
    }

    public int getXMin() {
        return xMin;
    }

    public int getXMax() {
        return xMax;
    }

    public int getZMin() {
        return zMin;
    }

    public int getZMax() {
        return zMax;
    }

    @Override
    public String toString() {
        return "(x-min=" + xMin + ", x-max=" + xMax + ", z-min=" + zMin + ", z-max=" + zMax + ")";
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == this) {
            return true;
        }

        if (!(obj instanceof PvPArea area)) {
            return false;
        }

        return this.xMin == area.getXMin() && this.xMax == area.getXMax()
                && this.zMin == area.getZMin() && this.zMax == area.getZMax();
    }

    @Override
    public int hashCode() {
        return Objects.hash(xMin, xMax, zMin, zMax);
    }
}

Notice that the overlapsAnyArea method scans through a set of areas and returns true if the current area overlaps with any of them. This works for now because the plugin was designed for a small server, but I will get into possible optimizations for the future below after reviewing the main plugin class and custom listener.

The plugin maintains a set of PvPArea objects and initializes a custom event listener that listens for any PlayerDeathEvent. If the player is within any of the PvP areas, we set keepInventory and keepLevel to true and prevent the player from dropping anything with event.getDrops().clear() and event.setDroppedExp(0). Here is the listener's code:

public class DeathListener implements Listener {
    private Set<PvPArea> areaSet;

    public DeathListener(Set<PvPArea> areaSet) {
        this.areaSet = areaSet;
    }

    public void setAreaSet(Set<PvPArea> areaSet) {
        this.areaSet = areaSet;
    }

    @EventHandler
    public void onPlayerDeath(PlayerDeathEvent event) {
        Player player = event.getPlayer();

        for (PvPArea area : areaSet) {
            if (area.hasPlayerWithin(player) && player.getWorld().getEnvironment() == World.Environment.NORMAL) {
                event.setKeepInventory(true);
                event.getDrops().clear();

                event.setKeepLevel(true);
                event.setDroppedExp(0);


                player.sendRichMessage("You died in a PvP area, so keepInventory and keepLevel were enabled.");
            }
        }
    }
}

The area data is saved in a World's PersistentDataContainer as an array of integers. The World is selected by choosing the first item from Bukkit.getWorlds() whose environment is World.Environment.NORMAL. This is a current limitation, and support for other types of worlds may be added in future releases. The array is formatted as follows: [xMin1, xMax1, zMin1, zMax1, xMin2, xMax2, zMin2, zMax2, ...] and is read in to the areaSet when the plugin is enabled. Any time an operator adds or clears all areas, the PersistentDataContainer is updated accordingly.

public class PvPAreaPlugin extends JavaPlugin {
    private final Set<PvPArea> areaSet = new HashSet<>();
    private final DeathListener areasListener = new DeathListener(areaSet);

    World world;
    PersistentDataContainer pdc;

    @Override
    public void onEnable() {
        getLogger().info("PvPArea enabled!");
        Keys.init(this);

        world = Bukkit.getWorlds().stream()
                .filter(w -> w.getEnvironment() == World.Environment.NORMAL)
                .findFirst()
                .orElseThrow();
        pdc = world.getPersistentDataContainer();
        int[] areasInts = pdc.get(Keys.PVP_AREA, PersistentDataType.INTEGER_ARRAY);

        if (areasInts == null || areasInts.length == 0) {
            getLogger().info("No areas found in PDC. Proceeding with 0 PvPAreas...");
        } else if (areasInts.length % 4 != 0) {
            getLogger().info("PDC integer array contains an incompatible number of ints.");
        } else {
            for (int i = 0; i < areasInts.length; i += 4) {
                int xMin = areasInts[i];
                int xMax = areasInts[i + 1];
                int zMin = areasInts[i + 2];
                int zMax = areasInts[i + 3];

                try {
                    PvPArea area = new PvPArea(xMin, xMax, zMin, zMax);

                    if (!area.overlapsAnyArea(areaSet)) {
                        areaSet.add(area);
                    } else {
                        getLogger().info("Area overlaps existing areas. Skipping...");
                    }
                } catch (RuntimeException e) {
                    getLogger().info("Invalid area. Skipping...");
                }
            }
        }

        getServer().getPluginManager().registerEvents(areasListener, this);
        registerCommands();
    }

    private void registerCommands() {
        ...
    }

    @Override
    public void onDisable() {
        getLogger().info("PvPArea disabled!");
    }
}

Finally, we register the commands for adding, listing, and clearing all areas. The /pvparea add command takes in 4 integers for the coordinates of the area and adds it to the areaSet and PersistentDataContainer if it doesn't overlap with any existing areas. The /pvparea list command sends a message to the player with all of the current areas, and the /pvparea clear-all command clears all areas from the set and clears the areaSet and PersistentDataContainer.

I will admit this code is a little messy. Blame the silly nested command builder syntax. I might clean it up, I might not.

    private void registerCommands() {
        LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal("pvparea");

        root.then(Commands.literal("list")
                .executes(ctx -> {
                    CommandSender sender = ctx.getSource().getSender();
                    if (areaSet.isEmpty()) {
                        sender.sendMessage("There are no PvP areas currently active.");
                        return Command.SINGLE_SUCCESS;
                    }

                    StringBuilder response;
                    if (areaSet.size() == 1) {
                        response = new StringBuilder("There is " + areaSet.size() + " PvP area currently active.\n");
                    } else {
                        response = new StringBuilder("There are " + areaSet.size() + " PvP areas currently active.\n");
                    }

                    int i = 0;
                    for (PvPArea a : areaSet) {
                        response.append("area").append(i + 1).append(": ").append(a.toString());
                        if (i != areaSet.size() - 1) {
                            response.append(", ");
                        }
                        i++;
                    }

                    sender.sendMessage(response.toString());
                    return Command.SINGLE_SUCCESS;
                })
        );
        root.then(Commands.literal("add")
                .requires(sender -> sender.getSender().isOp())
                .then(Commands.argument("x-min", IntegerArgumentType.integer())
                        .then(Commands.argument("x-max", IntegerArgumentType.integer())
                                .then(Commands.argument("z-min", IntegerArgumentType.integer())
                                        .then(Commands.argument("z-max", IntegerArgumentType.integer())
                                                .executes(ctx -> {
                                                    PvPArea newArea;
                                                    CommandSender sender = ctx.getSource().getSender();

                                                    try {
                                                        newArea = new PvPArea(
                                                                IntegerArgumentType.getInteger(ctx, "x-min"),
                                                                IntegerArgumentType.getInteger(ctx, "x-max"),
                                                                IntegerArgumentType.getInteger(ctx, "z-min"),
                                                                IntegerArgumentType.getInteger(ctx, "z-max")
                                                        );
                                                    } catch (RuntimeException e) {
                                                        sender.sendRichMessage("<red>Coordinate minimums must be " +
                                                                "less than maximums.</red>");
                                                        return Command.SINGLE_SUCCESS;
                                                    }

                                                    if (!newArea.overlapsAnyArea(areaSet)) {
                                                        areaSet.add(newArea);
                                                        int[] areasInts = pdc.get(Keys.PVP_AREA,
                                                                PersistentDataType.INTEGER_ARRAY);
                                                        if (areasInts == null) {
                                                            pdc.set(Keys.PVP_AREA, PersistentDataType.INTEGER_ARRAY,
                                                                    new int[] {
                                                                            newArea.getXMin(),
                                                                            newArea.getXMax(),
                                                                            newArea.getZMin(),
                                                                            newArea.getZMax()
                                                                    }
                                                            );
                                                        } else {
                                                            int[] newIntsArray = new int[areasInts.length + 4];
                                                            System.arraycopy(areasInts, 0, newIntsArray,
                                                                    0, newIntsArray.length - 4);

                                                            newIntsArray[newIntsArray.length - 4] = newArea.getXMin();
                                                            newIntsArray[newIntsArray.length - 3] = newArea.getXMax();
                                                            newIntsArray[newIntsArray.length - 2] = newArea.getZMin();
                                                            newIntsArray[newIntsArray.length - 1] = newArea.getZMax();

                                                            pdc.set(Keys.PVP_AREA, PersistentDataType.INTEGER_ARRAY,
                                                                    newIntsArray);
                                                        }

                                                        areasListener.setAreaSet(areaSet);
                                                        sender.sendRichMessage("Added new PvP area at " + newArea);
                                                    } else {
                                                        sender.sendRichMessage("<red>Area overlaps existing areas. " +
                                                                "Try different values.</red>");
                                                    }
                                                    return Command.SINGLE_SUCCESS;
                                                })
                                        )
                                )
                        )
                )
        );
        root.then(Commands.literal("clear-all")
                .requires(sender -> sender.getSender().isOp())
                .executes(ctx -> {
                    pdc.set(Keys.PVP_AREA, PersistentDataType.INTEGER_ARRAY, new int[]{});
                    areaSet.clear();

                    CommandSender sender = ctx.getSource().getSender();
                    sender.sendMessage("All PvP areas have been cleared.");
                    return Command.SINGLE_SUCCESS;
                })
        );

        LiteralCommandNode<CommandSourceStack> builtRoot = root.build();
        this.getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS,
                commands -> commands.registrar().register(builtRoot));
    }

Like I mentioned above, the methodology for checking if a player is within an area or for checking if an area overlaps another will become inefficient with a large number of areas or players. To improve this in the future, the Set of areas could be replaced with a HashMap<Integer, Set<PvPArea>> where each chunk overlapping any area is hashed and mapped to those area(s). Then, when a player dies or an area needs to be checked for overlap, we can hash the chunk(s) in question and only check against the areas mapped to those chunks.

- Tim