# Plugins

## Get Started

{% content-ref url="/pages/IGc2kbqbcfybdpwVnE5H" %}
[Modern BedWars](/products/modern-bedwars)
{% endcontent-ref %}

{% content-ref url="/pages/zW3JKVQJ9f3MvZwiKJXh" %}
[Obsidian License](/products/obsidian-license)
{% endcontent-ref %}

{% content-ref url="/pages/XAqVIN4ccoCWsspjgYD2" %}
[Modern OneBlock](/products/modern-oneblock)
{% endcontent-ref %}

{% content-ref url="/pages/zYaUKBt7LJ0F9hXpky7u" %}
[Modern Tournament](/products/modern-tournament)
{% endcontent-ref %}

{% content-ref url="/pages/h1qgEzDNp5dWMwf1qgn8" %}
[PlayerDataHistory](/products/playerdatahistory)
{% endcontent-ref %}

{% content-ref url="/pages/5iKmMkVlRns5rv1Uqtop" %}
[UltimateGuilds](/products/ultimateguilds)
{% endcontent-ref %}

{% content-ref url="/pages/jYP6OjYPmPYGXTi7OuKi" %}
[NetworkEconomy](/products/networkeconomy)
{% endcontent-ref %}


# Modern BedWars

A production-grade, network-scale BedWars core split across proxy, lobby, game and matchmaking modules.

{% hint style="info" %}
Modern BedWars is a **network suite**, not a drop-in single-server plugin. Read [architecture.md](/products/modern-bedwars/architecture) first so the rest of the docs make sense.
{% endhint %}

{% content-ref url="/pages/TXeprQJESoa0QavYKhSV" %}
[Features](/products/modern-bedwars/features)
{% endcontent-ref %}

{% content-ref url="/pages/CAbQJYoCIZThlGERYBUb" %}
[Architecture](/products/modern-bedwars/architecture)
{% endcontent-ref %}

{% content-ref url="/pages/3sCGarWC9jjiwkm1WKL6" %}
[Installation](/products/modern-bedwars/installation)
{% endcontent-ref %}

{% content-ref url="/pages/28tLFYsGGJBwvJ0gIpzS" %}
[Configuration](/products/modern-bedwars/configuration)
{% endcontent-ref %}

{% content-ref url="/pages/Ce6CvD9FSILDJhlgjy1K" %}
[Languages](/products/modern-bedwars/languages)
{% endcontent-ref %}

{% content-ref url="/pages/MS8bTIC4dXMq0dte93vI" %}
[Command & Permissions](/products/modern-bedwars/command-and-permissions)
{% endcontent-ref %}

{% content-ref url="/pages/XjZkeerOg67zL0vzV3vn" %}
[Troubleshooting](/products/modern-bedwars/troubleshooting)
{% endcontent-ref %}

{% content-ref url="/pages/bjSVoKIXd0JFhmGorylM" %}
[For Developers](/products/modern-bedwars/for-developers)
{% endcontent-ref %}


# Features

Modern BedWars is a horizontally scalable BedWars core. Everything player-facing — GUIs, prices, timings, physics constants, cosmetics and every string — is YAML/properties, not compiled into the jar.

## Why it's different

* **Horizontally scalable** — add lobby or game servers without touching the rest of the network's config.
* **Fully config-driven** — reskin GUIs and retune the whole game without a rebuild.
* **Multi-language out of the box** — per-player locale detection with automatic English fallback; add a language by dropping in a file.
* **Multi-version** — the game plugin runs on **both Minecraft 1.8 and 26.1+**.
* **Admin console included** — a live shell on the matchmaking service for queue / server / match inspection and map reloads.

## Gameplay systems (game server)

### Match lifecycle

Each game runs through `WAITING → STARTING → PLAYING → RESTARTING` phases. Game modes are **Solo, Duo, Trio and Squad**, each with independently tunable shop prices, upgrade prices and generator schedules.

### Teams & beds

* Each team has a colour, a shared shop inventory, a base region and its own bed.
* Breaking a team's bed eliminates it — remaining members become spectators, and a configurable bed-break cosmetic and message fire. The breaker is credited with a **final kill** on the last elimination.
* The post-respawn invulnerability window is configurable (default \~3s).

### Shop

* Categorised GUI (blocks, swords, armor, tools, bows, potions, special) driven by `shop.yml`, with per-game-mode pricing (solo/duo vs trio/squad tiers).
* Team-shared resource counters (iron, gold, diamond, emerald).
* An armor-hierarchy guard prevents accidentally buying a downgrade.
* Hotbar quick-buy for instant re-purchase without opening the GUI, plus a shop reset confirmation dialog.

### Generators

* Resource types: Iron, Gold, Diamond, Emerald, plus the team-upgraded **Emerald Forge**.
* Spawn delay, cap and batch size are configured per game mode **and** per upgrade level in `generators.yml`.

### Team upgrades

* Single-purchase upgrades (Sharpened Swords, Reinforced Armor, Heal Pool / Beacon) and multi-level ones (armor I–IV, haste pickaxe I–II, forge), priced per game mode.
* Grants persistent team-wide enchantments and effects for the rest of the match.

### Traps

* Configurable trap catalogue — shipped defaults include Tripwire (blind + slow attackers), Feather (speed + jump for defenders), Alarm / Redstone Torch (reveals invisible intruders) and Mining Fatigue.
* Each trap has a configurable potion effect, duration and target (attacking team vs defending base).
* A **magic-milk** cooldown (default \~30s) grants temporary trap immunity after triggering one.

### Cosmetics

* Categories: arrow trail, fireball trail, victory dance, final-kill effect, kill message, bed-break effect, bed-break message, wood skin.
* Players unlock/purchase cosmetics with in-game currency; unlocks and active selections persist in MariaDB.
* A master on/off toggle plus global particle-count and effect-duration multipliers let admins tune visual load without touching individual cosmetics.
* Paginated selector GUI.

### Disguise

* `/disguise` (game + lobby) backed by a disguise service, tracked in Redis so state follows the player across servers.

### Scoreboard

* Configurable per-phase sidebar; refresh rate is tunable to trade live-ness for tick performance on loaded game servers.

## Lobby systems

* **Queueing** — players join a mode queue; the matchmaking service does the actual matchmaking and tells the lobby to connect them.
* **Private games** — `/private` to host a custom match, `/join` to enter one by code.
* **Rejoin** — `/rejoin` returns a disconnected player to their in-progress match, tracked via Redis rejoin keys with a TTL.
* **Spectate** — `/spectate` to watch a running match.
* **Profile & stats** — `/profile` and `/stats` show win/loss/kill/final-kill records from MariaDB.
* **Leaderboards** — cached and periodically refreshed, with a reset job for seasonal resets.
* **Configurable GUI layout** — slot positions, page size and strings for the lobby GUIs are de-hardcoded; reskinning doesn't require a rebuild.
* **PlaceholderAPI** — optional soft dependency exposes `bedwars_*` placeholders (rank, coins, level, stats).

## Proxy systems

* **Routing** — recognises lobby vs game servers by configurable name prefixes and returns players to the lobby (or their match, via rejoin) on disconnect.
* **Party system** — `/party` with `chat`, `invite`, `join`, `kick`, `leader`, `leave`, `list`, built on a Redis-backed party model shared across the network.
* **Multiplier admin** — an XP/coin multiplier command for events and promotions.
* **Spectate** — cross-server `/spectate` entry point.

## Matchmaking brain (service)

A standalone Spring Boot process — no Minecraft server required, just a JVM with network access to Redis and MariaDB.

* **Queue processing** — one queue per game mode; runs a matchmaking pass on a tunable interval, steals players from under-filled queues, and times out players who wait too long.
* **Server rancher** — tracks live game servers, recomputes player load on an interval, and enforces a max-matches-per-server cap so no single instance gets overloaded.
* **Play-again cooldown** — a short cooldown + cache to stop `/playagain` abuse from hammering the queue.
* **Map distribution** — loads and broadcasts the map list to all game servers over Redis.
* **Admin console** — a Spring Shell prompt (`status queues`, `list matches`, `list servers`, `maps reload`, `log`, `stop`) plus an Actuator health endpoint.

## Party system

Two layers, selected automatically:

1. **Built-in** — Redis-backed, managed on the proxy via `/party`, with GUI support on the lobby.
2. **External plugin adapters** — if **Party and Friends** or **Parties** (AlessioDP) is installed, it's detected at startup and takes over as the read-only source of truth for party membership; the built-in `/party` command is automatically suppressed so there's no conflict. Queueing and party-follow-on-server-switch keep working either way.


# Architecture

Modern BedWars is made of **four independently deployable modules** plus a few shared libraries. The modules talk to each other **exclusively over Redis pub/sub** — there is no BungeeCord plugin-messaging channel to configure. MariaDB stores persistent data: stats, cosmetics, disguises and leaderboards.

## Topology

```
                    +-------------------+
        players --> | Velocity proxy    |   <-- bedwars-proxy plugin
                    +---------+---------+
                              |
              +---------------+---------------+
              |                               |
     +--------v---------+            +--------v---------+
     | Lobby server(s)  |            | Game server(s)   |
     | Paper            |            | Paper 1.8 / 26.1+ |
     | bedwars-lobby    |            | bedwars          |
     +--------+---------+            +--------+---------+
              |                               |
              +---------------+---------------+
                              |
                  Redis pub/sub + MariaDB
                              |
                    +---------v---------+
                    | bedwars-service   |   <-- standalone Spring Boot
                    +-------------------+
```

## Modules

| Module     | Jar                        | Runs on                      | Role                                                                         |
| ---------- | -------------------------- | ---------------------------- | ---------------------------------------------------------------------------- |
| `velocity` | `Bedwars-Velocity-1.0.jar` | Velocity proxy               | Routes players, `/party`, `/spectate`, cross-server multiplier admin command |
| `master`   | `Bedwars-Lobby-1.0.jar`    | Paper lobby server(s)        | Queueing, leaderboards, profile/cosmetic GUIs, private games                 |
| `instance` | `Bedwars-Instance-1.0.jar` | Paper game server(s)         | Match lifecycle: shop, generators, upgrades, traps, beds, scoreboard         |
| `service`  | `Bedwars-Service.jar`      | Standalone JVM (Spring Boot) | Matchmaking brain: queue processing, server allocation, map distribution     |

All four jars are self-contained ("fat") jars — no extra `lib/` folder is needed. The **service host does not need a Minecraft server**, just a JVM with network access to Redis and MariaDB.


# Installation

This walks you from a clean network to a running Modern BedWars stack: **Velocity proxy + lobby servers + game servers + standalone service**, backed by **Redis + MariaDB**.

{% hint style="info" %}
Start order is always **service → proxy → lobby → game** — the brain must be online before anything registers with it. Read [architecture.md](/products/modern-bedwars/architecture) first.
{% endhint %}

## 1. Requirements

| Component           | Version                  | Where                                                   |
| ------------------- | ------------------------ | ------------------------------------------------------- |
| Java                | **25**                   | Every node (proxy, lobby, game, service host)           |
| Paper               | 1.8 **or** 26.1+         | Lobby + game servers (game plugin is multi-version)     |
| Velocity            | 3.x latest               | Proxy                                                   |
| Redis               | 6.0+                     | Reachable by every node                                 |
| MariaDB / MySQL     | MariaDB 10.6+ or MySQL 8 | Reachable by every node                                 |
| Map-restore backend | pick one                 | Each game server needs **one** — see below              |
| LuckPerms           | latest                   | **Required** on every game server, recommended on lobby |
| PlaceholderAPI      | latest                   | Optional, lobby-side placeholders only                  |

> The `service` host does **not** need a Minecraft server — plain Java + network access to Redis + MariaDB is enough.

### Map-restore backend

Each game server needs exactly **one** way to reset maps between matches:

* **SlimeWorldManager (SWM)** — legacy slime worlds (good on 1.8).
* **Advanced Slime Paper (ASP)** — modern slime worlds with MySQL-shared storage; preferred on 26.1+ if installed.
* **Classic file restorer** (built-in fallback) — copies a template world folder per match. Requires the **VoidGen** plugin so surroundings stay void.

## 2. Infrastructure

### Redis

1. Install Redis on a host reachable by all nodes (default port `6379`).
2. (Recommended) set a password in `redis.conf`: `requirepass yourStrongPassword`.
3. Restart and verify: `redis-cli ping` → `PONG`.

### MariaDB

1. Install MariaDB 10.6+ (or MySQL 8).
2. Create the database and a dedicated user:

   ```sql
   CREATE DATABASE bedwars CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
   CREATE USER 'bedwars'@'%' IDENTIFIED BY 'yourStrongPassword';
   GRANT ALL PRIVILEGES ON bedwars.* TO 'bedwars'@'%';
   FLUSH PRIVILEGES;
   ```
3. Apply the shipped schema (`database.sql`):

   ```bash
   mysql -h <host> -u bedwars -p bedwars < database.sql
   ```

   This creates tables for `bedwars_users`, `bedwars_stats`, `bedwars_leaderboard`, `bedwars_multipliers`, `bedwars_presets`, `bedwars_user_cosmetics`, `bedwars_active_cosmetics` and `disguises`.

## 3. Install the service (start it first)

1. Copy `Bedwars-Service.jar` to a host.
2. Next to the jar, create `application.properties` with your Redis and MariaDB connection details (see [configuration.md](/products/modern-bedwars/configuration)).
3. Start it: `java -jar Bedwars-Service.jar`.
4. You land in a Spring Shell prompt (`shell:>`). Run `list servers` — an empty table is expected (no game servers yet).

To run it as a Linux service, use a `systemd` unit:

```ini
[Unit]
Description=Modern BedWars Service
After=network-online.target

[Service]
User=bedwars
WorkingDirectory=/opt/bedwars-service
ExecStart=/usr/bin/java -jar Bedwars-Service.jar
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
```

## 4. Install the proxy plugin

1. Drop `Bedwars-Velocity-1.0.jar` into the Velocity `plugins/` directory.
2. Start the proxy once to generate `plugins/bedwars-proxy/` with `mariadb.json`, `redis.json` and `config.json`.
3. Fill in the DB/Redis credentials, and adjust `config.json` server-name prefixes if you use different names (see [configuration.md](/products/modern-bedwars/configuration)).
4. In `velocity.toml`, register every lobby and game server; the names must match the prefixes from `config.json`.
5. Restart the proxy.

## 5. Install the lobby plugin (every lobby server)

1. Drop `Bedwars-Lobby-1.0.jar` into the lobby Paper `plugins/` folder.
2. Start once to generate `plugins/bedwars-lobby/`.
3. Edit `config.yml`: set a unique `server-id` (e.g. `bedwars_lobby_1`) and the DB/Redis credentials.
4. (Optional) install **PlaceholderAPI** for `bedwars_*` placeholders.
5. Restart.

{% hint style="warning" %}
The lobby plugin refuses to enable if the database `password` is empty. Set it.
{% endhint %}

## 6. Install the game plugin (every game server)

1. Install your chosen **map-restore backend** and **LuckPerms** first.
2. Drop `Bedwars-Instance-1.0.jar` into the game Paper `plugins/` folder.
3. Start once to generate `plugins/bedwars/` and its `defaults/` directory.
4. Edit `config.yml`. The `server-id` must match the name you registered in `velocity.toml` **and** start with the proxy's `gamePrefix` (default `bedwars_game_`).
5. Tune `defaults/*.yml` as needed — see [configuration.md](/products/modern-bedwars/configuration).
6. Restart. On enable the server registers itself with the service over Redis; verify with `list servers` in the service shell.

## 7. Verify

Connect a Minecraft client to the proxy. You should land in the lobby, see the queue GUI, and be able to start a match.


# Configuration

Every gameplay-affecting value ships as an editable default and is **not** recompiled to change. Configuration is split by module.

## Service (`application.properties`)

The standalone service reads a plain `application.properties` next to its jar:

```properties
bedwars.service.redis.host=<redis-host>
bedwars.service.redis.port=6379
bedwars.service.redis.password=<redis-password>
bedwars.service.redis.database=0

bedwars.service.maria.host=<mariadb-host>
bedwars.service.maria.port=3306
bedwars.service.maria.database=bedwars
bedwars.service.maria.username=bedwars
bedwars.service.maria.password=<mariadb-password>

bedwars.service.server.max-match-per-server=50
# Online-player recompute interval for the server rancher (ms).
bedwars.service.server.online-update-interval-ms=2000

# Queue tuning (all optional — defaults shown).
bedwars.service.queue.task-interval-ms=50
bedwars.service.queue.redis-update-interval-ms=2500
bedwars.service.queue.max-null-count=15
bedwars.service.queue.timeout-ms=5000
bedwars.service.queue.idle-transition-delay-ms=1000
bedwars.service.queue.play-again-cooldown-seconds=3
bedwars.service.queue.play-again-cache-size=10000

logging.level.com.ohalee.bedwars=info
logging.file.name=./logs/bedwars-service.log
```

### Advanced — Redis key TTLs (any node)

The shared platform layer keeps a few Redis TTLs that rarely need changing. They're tunable on **any** node via JVM system properties (defaults, in seconds):

```
-Dbedwars.platform.redis.rejoin-ttl-seconds=300
-Dbedwars.platform.redis.match-ttl-seconds=10
-Dbedwars.platform.redis.party-invite-ttl-seconds=60
-Dbedwars.platform.redis.player-load-ttl-seconds=20
```

## Proxy (`plugins/bedwars-proxy/`)

Three JSON files:

**`mariadb.json`** and **`redis.json`** — connection details (host, port, username, password, database, pool sizes).

**`config.json`** — server-name prefixes. These are how the proxy recognises lobby vs game servers and where it returns players on leave. Defaults:

```json
{
  "bedwarsPrefix": "bedwars_",
  "lobbyPrefix": "bedwars_lobby_",
  "gamePrefix": "bedwars_game_",
  "hubPrefix": "lobby_",
  "defaultLobbyServer": "bedwars_lobby_1"
}
```

Register matching server names in Velocity's `velocity.toml`; each game server's own `server-id` must use the same name.

## Lobby (`plugins/bedwars-lobby/config.yml`)

```yaml
# Must be unique per lobby node, e.g. bedwars_lobby_1, _2, ...
server-id: "bedwars_lobby_1"

database:
  mariadb:
    host: "<mariadb-host>"
    port: 3306
    username: "bedwars"
    password: "<mariadb-password>"
    database-name: "bedwars"
    pool-name: "bedwars-pool"
    minimum-pool-size: 10
    maximum-pool-size: 10
  redis:
    host: "<redis-host>"
    port: 6379
    password: "<redis-password>"
    database: 0
    clientName: "bedwars-redis"
    minimumPoolSize: 10
    maximumPoolSize: 10
```

Lobby GUI layout (slot positions, page size, strings) lives in the lobby's own config and is de-hardcoded — reskinning doesn't require a rebuild.

## Game (`plugins/bedwars/`)

`config.yml` holds the `server-id` (must match `velocity.toml` and start with the proxy's `gamePrefix`) and the same database block as the lobby.

Everything gameplay-affecting is in `defaults/`. Edit only the values you want to override:

| File             | Governs                                                                                                                                                       |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `game.yml`       | Match timers/sizes, combat rules, egg-bridge, fireball physics, sponge absorption, magic-milk cooldown, trap-alarm timing, cosmetic tuning toggle/multipliers |
| `shop.yml`       | Shop GUI layout + item prices per game mode + team upgrade tiers/prices                                                                                       |
| `generators.yml` | Spawn rate/cap/amount per resource, mode and level                                                                                                            |
| `upgrades.yml`   | Upgrade & trap GUI layout (slots, cards, filler)                                                                                                              |
| `traps.yml`      | Trap catalogue, effects, targets + GUI layout                                                                                                                 |
| `cosmetics.yml`  | Cosmetic catalogue (name, rarity, price, permission) + GUI layout/pagination                                                                                  |
| `scoreboard.yml` | Sidebar layout + refresh rate                                                                                                                                 |
| `hotbar.yml`     | Hotbar layout per game phase                                                                                                                                  |
| `items.yml`      | Special-item definitions                                                                                                                                      |
| `events.yml`     | Game event timeline (diamond II, generator destroy, etc.)                                                                                                     |

{% hint style="warning" %}
The plugins do **not** auto-merge missing keys on upgrade. After replacing a jar, skim the shipped defaults against your edited copy and copy in any **new** keys with their defaults — otherwise you can't tweak the new key until you add it.
{% endhint %}


# Languages

Modern BedWars is multi-language out of the box. Each player automatically receives messages in their **Minecraft client locale**, falling back to `en_US` for any missing key.

## Where the files live

Translations are per-module `.properties` files under each module's `languages/` folder:

```
plugins/bedwars-lobby/languages/messages_en_US.properties
plugins/bedwars-lobby/languages/messages_it_IT.properties
plugins/bedwars/languages/messages_en_US.properties
plugins/bedwars/languages/messages_it_IT.properties
plugins/bedwars-proxy/languages/messages_en_US.properties
plugins/bedwars-proxy/languages/messages_it_IT.properties
```

English (`en_US`) and Italian (`it_IT`) ship by default.

## Adding a new language

Example — French (`fr_FR`):

1. Copy `messages_en_US.properties` → `messages_fr_FR.properties`.
2. Translate the **values** (keys stay in English).
3. Drop the file in the same `languages/` folder. The plugin discovers it on reload.

Repeat per module you want translated.

{% hint style="info" %}
The standalone **service** logs in English only, by design — operators read those logs, not players.
{% endhint %}


# Command & Permissions

Permission nodes use the `bedwars.command.*` namespace. Grant them with LuckPerms (**required** on every game server).

## Lobby commands (`bedwars-lobby`)

<table><thead><tr><th width="180">Command</th><th width="280">Permission</th><th>Capability</th></tr></thead><tbody><tr><td>/bw</td><td>bedwars.command.bw</td><td>Open the main BedWars menu / queue</td></tr><tr><td>/private (create)</td><td>bedwars.command.private</td><td>Host a custom private match</td></tr><tr><td>/join (entra)</td><td>bedwars.command.join</td><td>Enter a private match by code</td></tr><tr><td>/rejoin (rientra)</td><td>bedwars.command.rejoin</td><td>Return to an in-progress match</td></tr><tr><td>/spectate (spec)</td><td>bedwars.command.spectate</td><td>Watch a running match</td></tr><tr><td>/stats</td><td>bedwars.command.stats</td><td>Show win/loss/kill records</td></tr><tr><td>/profile (profilo)</td><td>bedwars.command.profile</td><td>Open the profile GUI</td></tr><tr><td>/cosmetic</td><td>bedwars.command.cosmetic</td><td>Open the cosmetics selector</td></tr><tr><td>/disguise</td><td>bedwars.command.disguise</td><td>Disguise as another player</td></tr></tbody></table>

## Game commands (`bedwars`)

<table><thead><tr><th width="180">Command</th><th width="280">Permission</th><th>Capability</th></tr></thead><tbody><tr><td>/playagain</td><td>bedwars.command.playagain</td><td>Requeue into a new match</td></tr><tr><td>/leave</td><td>bedwars.command.leave</td><td>Leave the current match</td></tr><tr><td>/stats</td><td>bedwars.command.stats</td><td>Show stats in-game</td></tr><tr><td>/disguise</td><td>bedwars.command.disguise</td><td>Disguise as another player</td></tr><tr><td>/shopstats</td><td>bedwars.admin</td><td>Admin shop/debug tooling</td></tr></tbody></table>

## Proxy commands (`bedwars-proxy`)

These are registered programmatically — no extra configuration needed.

<table><thead><tr><th width="220">Command</th><th>Capability</th></tr></thead><tbody><tr><td>/party (/p, /parties)</td><td>Party management: chat, invite, join, kick, leader, leave, list</td></tr><tr><td>/spectate</td><td>Cross-server spectate entry point</td></tr><tr><td>/bwmoltiplicator</td><td>Admin XP/coin multiplier for events</td></tr></tbody></table>

## Example LuckPerms setup

Run on each Bukkit server:

```
lp group default permission set bedwars.command.bw true
lp group default permission set bedwars.command.stats true
lp group default permission set bedwars.command.profile true
lp group default permission set bedwars.command.cosmetic true
lp group default permission set bedwars.command.join true
lp group default permission set bedwars.command.rejoin true
lp group default permission set bedwars.command.spectate true
lp group default permission set bedwars.command.playagain true
lp group default permission set bedwars.command.leave true

lp group admin parent add default
lp group admin permission set bedwars.command.private true
lp group admin permission set bedwars.command.disguise true
lp group admin permission set bedwars.admin true
```

## Service admin console

The standalone service exposes a Spring Shell prompt (not a Minecraft command) — no permission node applies. Available commands: `status queues`, `list matches`, `list servers`, `maps reload`, `log`, `stop`.


# Troubleshooting

Most first-run issues are a missing password or a Redis/MariaDB reachability problem. Work through the table before opening a support ticket.

<table><thead><tr><th width="330">Symptom</th><th>Likely cause</th></tr></thead><tbody><tr><td><code>list servers</code> in the service shell stays empty</td><td>A game server isn't registering — check its <code>server-id</code> matches the proxy's <code>gamePrefix</code> (<code>config.json</code>) and that it points at the same Redis as the service.</td></tr><tr><td>Players stuck in queue</td><td>No instance is reporting heartbeats; or <code>max-match-per-server</code> is <code>0</code>; or every instance is already at capacity.</td></tr><tr><td>Lobby/game plugin fails to enable with a DB error</td><td><code>config.yml</code> <code>password</code> is empty — set it.</td></tr><tr><td>GUIs render raw keys like <code>master.profile.title</code></td><td>The key is missing from the active language file. Copy it from <code>messages_en_US.properties</code>.</td></tr><tr><td>Tick lag on game servers under load</td><td>Raise the sidebar refresh interval in <code>defaults/scoreboard.yml</code> (refresh the scoreboard less often).</td></tr><tr><td>A node stops receiving messages</td><td>Different Redis instance, wrong Redis password, different <code>database:</code> index, or a network policy blocking port 6379.</td></tr></tbody></table>

## Log locations

* **Service:** `./logs/bedwars-service.log` (rotated daily, capped at 1 GB).
* **Bukkit plugins:** standard Paper console + `logs/latest.log`.

## Connectivity checks

Test Redis from a node:

```bash
redis-cli -h <redis-host> -p 6379 -a <password> ping
```

Test MariaDB from a node:

```bash
mysql -h <mariadb-host> -u bedwars -p bedwars -e "SELECT 1;"
```

## Start / stop order

* **Start:** service → proxy → lobby → game (brain up first so registrations succeed).
* **Stop:** game → lobby → proxy → service (brain down last so in-flight messages have somewhere to go).

## Upgrading a jar

1. Stop the affected node.
2. Replace the `Bedwars-<module>-1.0.jar` with the new build.
3. **Keep** your existing `config.yml` / `application.properties` / `mariadb.json` / `redis.json` / `config.json` and any edited `defaults/*.yml`.
4. Skim shipped defaults vs your copy for **new** keys and copy them in (the plugins don't auto-merge missing keys).
5. Start the node.


# For Developers

The `api` module exposes stable, public interfaces for integrating with Modern BedWars — matches, parties, cosmetics and the Redis key catalogue. These are the same types shared across every module.

## Match

`Match<T extends MatchType>` describes a single game. Statuses flow `WAITING → STARTING → PLAYING → RESTARTING`; the first two are matchmaking phases.

```java
public interface Match<T extends MatchType> {
    @Nullable UUID hostUUID();
    int teamSize();
    int totalCount();
    boolean canCarry(int weight);
    T type();
    String name();
    void status(MatchStatus status);
    MatchStatus status();
    String map();
    int maxPlayers();
    String server();
    Date created();
    void closed(boolean closed);
    boolean closed();
}
```

```java
public enum MatchStatus {
    WAITING(true),
    STARTING(true),
    PLAYING(false),
    RESTARTING(false);
    // matchmaking() -> true for WAITING/STARTING
}
```

`MatchType` abstracts the game mode (Solo, Duo, Trio, Squad):

```java
public interface MatchType {
    String displayName();
    int maxTeams();
    int teamSize();
    int minPlayers();
}
```

## Party

Parties are immutable value objects — the `with*` methods return a new instance.

```java
public interface Party {
    UUID id();
    boolean isMember(UUID uuid);
    Set<UUID> members();
    UUID leader();
    boolean isLeader(UUID uuid);
    int size();
    Party withLeader(UUID newLeader);
    Party withMember(UUID member);
    Party withoutMember(UUID member);
}
```

### Integrating an external party plugin

`PartyProvider` is the **read-only** integration point for external party plugins (Party and Friends, Parties/AlessioDP, or your own). Set it via `bootstrap.getPartyManager().setPartyProvider(provider)`. When set, the built-in Redis party system is bypassed for reads and the Velocity `/party` command is disabled automatically.

```java
public interface PartyProvider {
    CompletionStage<Optional<Party>> getParty(UUID playerId);
    CompletionStage<Boolean> hasParty(UUID playerId);
    CompletionStage<Boolean> isLeader(UUID playerId);

    static PartyProvider noop(); // disable built-in commands without providing data
}
```

Use `PartyProvider.noop()` to disable the built-in party system entirely — e.g. on the Velocity side when a Bukkit-side party plugin handles everything.

## Cosmetics

A `Cosmetic` is a registered, unlockable effect in one of eight categories.

```java
public interface Cosmetic {
    int id();                              // matches the DB id
    String name();                         // internal name
    String displayName();
    List<String> description();
    CosmeticCategory category();
    CosmeticRarity rarity();
    int cost();                            // coin cost to unlock
    boolean isUnlockedByDefault();
    Optional<String> specialPermission();  // permission gate, if any
}
```

Categories (`CosmeticCategory`): `ARROW_TRAIL`, `FIREBALL_TRAIL`, `VICTORY_DANCE`, `FINAL_KILL_EFFECT`, `KILL_MESSAGE`, `BED_BREAK_EFFECT`, `BED_BREAK_MESSAGE`, `WOOD_SKIN`.

`CosmeticManager` registers cosmetics and manages per-player unlocks and active selections. Blocking operations return `CompletableFuture`s; there are cached (synchronous) variants for hot paths:

```java
public interface CosmeticManager {
    void registerCosmetic(Cosmetic cosmetic);
    Optional<Cosmetic> getCosmetic(String name);
    Optional<Cosmetic> getCosmetic(int id);
    List<Cosmetic> getCosmeticsByCategory(CosmeticCategory category);
    Collection<Cosmetic> getAllCosmetics();

    CompletableFuture<Boolean> hasUnlocked(UUID player, Cosmetic cosmetic);
    CompletableFuture<Boolean> unlockCosmetic(UUID player, Cosmetic cosmetic);
    CompletableFuture<Boolean> setActiveCosmetic(UUID player, Cosmetic cosmetic);
    void setAndForgetActiveCosmetic(UUID player, Cosmetic cosmetic);   // fire-and-forget
    CompletableFuture<Optional<Cosmetic>> getActiveCosmetic(UUID player, CosmeticCategory category);
    CompletableFuture<List<Cosmetic>> getUnlockedCosmetics(UUID player);

    // Cache-backed, synchronous — safe on the main thread once loadPlayerData completed
    Optional<Cosmetic> getCachedActiveCosmetic(UUID player, CosmeticCategory category);
    Optional<Cosmetic> getCachedEffectiveCosmetic(UUID player, CosmeticCategory category);
    List<Cosmetic> getCachedUnlockedCosmetics(UUID player);

    CompletableFuture<Void> loadPlayerData(UUID player);
    void unloadPlayerData(UUID player);
}
```

{% hint style="warning" %}
Never block on a `CompletableFuture` (`get()` / `join()`) on the main server thread. Use the cached variants once `loadPlayerData` has completed, or chain with `thenAcceptAsync`.
{% endhint %}

## Statistics

`StatsType` enumerates tracked stats and their PlaceholderAPI names:

| Stat             | Placeholder    |
| ---------------- | -------------- |
| `BEDS_BROKEN`    | `BED`          |
| `KILLS`          | `KILLS`        |
| `FINAL_KILLS`    | `FINALKILLS`   |
| `DEATHS`         | `DEATH`        |
| `FINAL_DEATHS`   | `FINALDEATHS`  |
| `WINS`           | `WINS`         |
| `LOSSES`         | `LOSSES`       |
| `WIN_STREAK`     | `WINSTREAK`    |
| `MAX_WIN_STREAK` | `MAXWINSTREAK` |
| `TOTAL_KILLS`    | `TOTALKILLS`   |

## Redis key catalogue

For diagnostics and low-level integration, `RedisKeys` centralises every key (`%s` are format arguments):

| Key                     | Pattern                   | Holds                             |
| ----------------------- | ------------------------- | --------------------------------- |
| `BEDWARS_MAPS`          | `bedwars:maps`            | Broadcast map list                |
| `BEDWARS_PLAYER_LOAD`   | `bedwars:player-load:%s`  | Per-server player load            |
| `DISGUISE_NAME`         | `bedwars:disguise`        | Disguise names                    |
| `DISGUISE_ACTIVE_NAMES` | `bedwars:active-disguise` | Active disguises                  |
| `PARTY`                 | `party:%s`                | Party hash (leader + fields)      |
| `PARTY_INVITE`          | `party:invite:%s:%s`      | Party invite data                 |
| `MATCH`                 | `bedwars:match:%s`        | Match hash                        |
| `PLAYER_IN_MATCH`       | `bedwars:player:%s`       | Player → match id                 |
| `PLAYER_REJOIN`         | `bedwars:rejoin:%s`       | Rejoin hash (TTL)                 |
| `SERVER`                | `bedwars:server:%s`       | Game-server heartbeat (short TTL) |

All cross-server traffic is Redis pub/sub via the internal `redis-bridge` library — there is no BungeeCord plugin-messaging channel.


# Obsidian License

A license-management and anti-piracy platform for Java software: Paper and Velocity plugins, and any plain Java application.

Obsidian License is a hosted platform at [license.ohalee.com](https://license.ohalee.com). You create a product, issue keys, and your software refuses to run without a valid one. Keys are bound to machines, revocable, expirable, and every activation is logged.

It works on **Paper** (Bukkit, Spigot), **Velocity**, and **plain Java**: a Spring Boot service, a CLI tool, a daemon, anything with a JVM. Same licenses, same dashboard, same code underneath.

The interfaces and the client you compile against are public: [github.com/ohAleee/obsidianlicense-api](https://github.com/ohAleee/obsidianlicense-api).

## Pick your mode

There are two ways to use it, and you choose per product. Both share the same licenses, seats, entitlements, revocation, marketplace automation and logs, so you can start with the first and move to the second later without reissuing keys.

<table><thead><tr><th width="180">Mode</th><th>Use it when</th><th width="150">Effort</th></tr></thead><tbody><tr><td><a href="/products/obsidian-license/license-check"><strong>License check</strong></a></td><td>You want licensing: seats, expiry, a refund kill switch, tiers. Your plugin ships as a normal jar and calls the server on startup.</td><td>One method call</td></tr><tr><td><a href="/products/obsidian-license/protected-plugins"><strong>Protected plugin</strong></a></td><td>You also need the compiled code itself kept off the customer's disk. Customers install a loader that decrypts and runs your plugin in memory.</td><td>Restructure the plugin</td></tr></tbody></table>

Not sure? Start with the license check. It solves the problem most sellers actually have (shared keys, refunded copies still running, no way to cut anyone off) and takes about five minutes.

{% content-ref url="/pages/4k8LkS4TyCBd4qYpuRwJ" %}
[Quick Start](/products/obsidian-license/quick-start)
{% endcontent-ref %}

## The docs

{% content-ref url="/pages/CII0mMu4NH4nGjzGeRxZ" %}
[Features](/products/obsidian-license/features)
{% endcontent-ref %}

{% content-ref url="/pages/qN89NWMaz5k7q0wyS35d" %}
[How It Works](/products/obsidian-license/how-it-works)
{% endcontent-ref %}

{% content-ref url="/pages/OZ6Q8prNK1wKWSW3mAgX" %}
[License Check](/products/obsidian-license/license-check)
{% endcontent-ref %}

{% content-ref url="/pages/3zLslhiHA51mmljgLzaa" %}
[Protected Plugins](/products/obsidian-license/protected-plugins)
{% endcontent-ref %}

{% content-ref url="/pages/1G8AxHlQCR0MjzGCflhN" %}
[Ship Your Own Loader](/products/obsidian-license/your-own-loader)
{% endcontent-ref %}

{% content-ref url="/pages/ISJa2GrELAQk92xygelO" %}
[Tiers & Entitlements](/products/obsidian-license/tiers-and-entitlements)
{% endcontent-ref %}

{% content-ref url="/pages/tSGSVN7DTpiBmRSvNeTj" %}
[Several Jars, One License](/products/obsidian-license/multiple-jars)
{% endcontent-ref %}

{% content-ref url="/pages/YUA6gH5ssRQZ8q9owpa3" %}
[Downloading the Jar](/products/obsidian-license/downloads)
{% endcontent-ref %}

{% content-ref url="/pages/4bawbw2aNPdyThzZgzPd" %}
[Configuration](/products/obsidian-license/configuration)
{% endcontent-ref %}

{% content-ref url="/pages/rQ9sZF3XNWvBaf3AtMuN" %}
[Marketplace Automation](/products/obsidian-license/marketplace-automation)
{% endcontent-ref %}

{% content-ref url="/pages/HkTNlYr1pCvnGk6lMtjm" %}
[Security Model](/products/obsidian-license/security)
{% endcontent-ref %}

{% content-ref url="/pages/LyFpwkBBglciptvWFTlW" %}
[For Developers](/products/obsidian-license/for-developers)
{% endcontent-ref %}

{% content-ref url="/pages/oFgVaEjNkGxeWSrNlwX0" %}
[AI Agent (MCP)](/products/obsidian-license/mcp-server)
{% endcontent-ref %}

{% hint style="info" %}
These docs describe the **hosted** service. Everything you need, the loader jar, your server URL and public key, license keys and marketplace webhooks, comes from your dashboard. Obsidian License is also self-hostable if you would rather run your own instance and own your database and signing keys: ask support if that is you.
{% endhint %}


# Quick Start

Add licensing to an existing plugin in about five minutes, without changing how the plugin is built or shipped.

This is the fastest way to license a plugin. Your plugin stays an ordinary jar. You add one method call to `onEnable`, and it refuses to start without a valid license.

If you want the compiled code itself kept off the customer's disk, that is the other mode: see [protected-plugins.md](/products/obsidian-license/protected-plugins). You can start here and move later without reissuing any keys.

## 1. Create a product

In the [dashboard](https://license.ohalee.com), go to **Products** and create one. The **Product ID** you choose (for example `my-plugin`) is what your code will send. Nothing to upload yet.

## 2. Copy your server key

Go to **Dashboard, Server key** and copy the Ed25519 public key. Your plugin pins it so that nobody can point your customers at a fake license server and get a free "yes".

## 3. Drop in the client

Copy [`ObsidianLicense.java`](https://github.com/ohAleee/obsidianlicense-api/blob/main/core/src/main/java/com/obsidian/license/core/ObsidianLicense.java) from the public API repository into your project and change the package to match. It is a single file with no dependencies beyond Gson, which Paper and Velocity already ship. There is nothing to add to your build file.

Prefer a dependency? The same code is on JitPack:

```kotlin
repositories { maven("https://jitpack.io") }
dependencies { implementation("com.github.ohAleee.obsidianlicense-api:core:v1.2.1") }
```

The example below is Paper. The client itself is platform-neutral, so the same call works on Velocity and in a plain Java application: see [license check](/products/obsidian-license/license-check).

## 4. Call it on startup

```java
public final class MyPlugin extends JavaPlugin {

    private ObsidianLicense.Result license;

    @Override
    public void onEnable() {
        saveDefaultConfig();
        var cfg = getConfig();

        var client = new ObsidianLicense(
                cfg.getString("license.serverUrl"),
                cfg.getString("license.serverPublicKey"));

        try {
            license = client.check(
                    cfg.getString("license.key"),
                    "my-plugin",                                    // your Product ID
                    ObsidianLicense.machineId(getDataFolder().toPath()));
        } catch (ObsidianLicense.LicenseException e) {
            // Fail closed. The message is written for the customer, print it as-is.
            getLogger().severe("License check failed: " + e.getMessage());
            getServer().getPluginManager().disablePlugin(this);
            return;
        }

        getLogger().info("License OK");
        // your normal startup continues here
    }
}
```

And the `config.yml` your customers fill in:

```yaml
license:
  serverUrl: "https://license.ohalee.com"
  key: "OBS-XXXX-XXXX-XXXX-XXXX"
  serverPublicKey: "PASTE_THE_KEY_FROM_YOUR_DASHBOARD"
```

Only `key` changes per customer. The other two are the same for everyone who buys that product, so ship them pre-filled.

## 5. Issue a key and test

In **Licenses**, create a license for your product. Paste it into a test server's `config.yml` and start Paper. The plugin enables, and the activation shows up in your dashboard's **Access log** with the IP and machine id.

Then try it with the key revoked: the plugin refuses to enable and prints the reason. That is the whole point.

## What you now have

* Seats. A license runs on a set number of machines, and customers free a seat themselves at `/portal` when they migrate servers.
* A kill switch. Revoke a key after a refund or a leak and it stops working.
* Expiry, for subscriptions and time-limited deals.
* Tiers and entitlements, so one build can sell as Basic and Premium. See [tiers-and-entitlements.md](/products/obsidian-license/tiers-and-entitlements).
* Several jars under one key, if your product is a lobby plugin plus a game plugin. See [multiple-jars.md](/products/obsidian-license/multiple-jars).
* A full access log of every attempt, allowed or denied.

## Next steps

{% content-ref url="/pages/OZ6Q8prNK1wKWSW3mAgX" %}
[License Check](/products/obsidian-license/license-check)
{% endcontent-ref %}

{% content-ref url="/pages/YUA6gH5ssRQZ8q9owpa3" %}
[Downloading the Jar](/products/obsidian-license/downloads)
{% endcontent-ref %}

{% content-ref url="/pages/rQ9sZF3XNWvBaf3AtMuN" %}
[Marketplace Automation](/products/obsidian-license/marketplace-automation)
{% endcontent-ref %}


# Features

## Runs on

**Paper** (Bukkit, Spigot), **Velocity**, and **plain Java**: a Spring Boot service, a CLI tool, a daemon, anything with a JVM. One set of licenses and one dashboard across all of them, and the interfaces you compile against are public at [github.com/ohAleee/obsidianlicense-api](https://github.com/ohAleee/obsidianlicense-api).

## The problem it solves

Minecraft plugin developers sell jars on marketplaces like BuiltByBit or Voxel (Polymart). A plain `.jar` is trivial to copy and reshare: a buyer of one license can hand the file to hundreds of servers with no way for the developer to detect or stop it, and a refunded buyer keeps running it forever. Obsidian License gives the developer a live, revocable control plane over every copy in the wild, and can additionally remove the plaintext jar from the equation entirely.

## Two ways to use it

* **License check**, the simple one. Your plugin ships as a normal jar and calls the server on startup for a signed yes or no. One method call, no restructuring. See [license-check.md](/products/obsidian-license/license-check).
* **Protected plugin**, the strong one. The compiled plugin never lands on the customer's disk; a thin loader decrypts and runs it in memory. See [protected-plugins.md](/products/obsidian-license/protected-plugins).

Both share everything below, and you pick per product.

## Core capabilities

* **Encryption and code protection** (protected mode): AES-256-GCM at rest, Ed25519-signed activation responses, in-memory class loading, so no plaintext jar ever lands on a customer's disk.
* **Signed license checks** (both modes): the answer, the tier and the entitlements are signed against a nonce your plugin chose, so a fake server or a replayed response cannot grant a license.
* **Licensed downloads**: let a valid license pull the current build straight from the platform, and a revoked key stops downloading immediately. See [downloads.md](/products/obsidian-license/downloads).
* **License management**: create, edit, revoke and delete keys, with seats (machine-activation limits), expiry dates, notes, bulk issuance and CSV export.
* **Tiers and entitlements**: attach a tier label and an arbitrary entitlement bag (feature flags, limits) to each key, delivered inside the signed response so one product can sell Basic and Premium with no separate builds. See [tiers-and-entitlements.md](/products/obsidian-license/tiers-and-entitlements).
* **Several jars per product**: ship a lobby plugin and a game plugin under one product and one key, and let each server pull the jar it needs. See [multiple-jars.md](/products/obsidian-license/multiple-jars).
* **Versioned releases and channels**: every upload is a retained version. Put trusted customers on a **beta** channel and promote or roll back instantly. Each server picks up the right build on its next start based on its license channel.
* **Free trial keys**: enable a self-serve trial per product and share one link. Prospects claim a time-limited, seat-limited key (deduped per IP) and convert themselves.
* **Machine binding**: each license is bound to a configurable number of machines, resettable when a customer migrates hardware, by you or by the buyer from the self-service portal.
* **Self-service customer portal**: buyers enter their key to see status and bound machines, and free a seat themselves when they move servers (rate-limited). This is the number one DRM support ticket, answering itself.
* **Custom branding**: put your name, accent colour, logo and support link on the customer-facing redeem, status, portal and trial pages so the whole flow feels like your product.
* **Access logs**: every attempt, allowed or denied, recorded with IP, OS, Java version and machine fingerprint, streamed live in the dashboard and exportable.
* **Abuse protection**: per-IP auto-ban and per-license rate limiting to blunt sharing and brute-force attempts.
* **Marketplace automation**: connect **BuiltByBit** and **Voxel** (formerly Polymart) with your own private, per-tenant webhook and secret. A purchase auto-issues a key, a refund or chargeback auto-revokes it. See [marketplace-automation.md](/products/obsidian-license/marketplace-automation).
* **Analytics**: activation trends, active installs, denials and top products.

## The SaaS / business layer

* **Plans & billing** - Stripe-backed subscriptions (Checkout + a self-service Billing Portal). Plans gate `maxProducts`, `maxLicenses`, `maxSeatsPerLicense`, marketplace integrations and each growth feature above. A free tier ships out of the box; failed payments and cancellations automatically fall a tenant back to the free plan.
* **Team and reseller accounts**: invite teammates to manage products and licenses without sharing billing or login credentials, or add scoped **resellers** who can only issue keys for the products you assign them. Invitations to an existing account have to be accepted before anything changes for that account.
* **AI agent support (MCP)**: point Claude, Cursor or any MCP client at the platform and it can read the implementation guides, scaffold a licensed plugin, create products, upload builds and issue keys for you. See [mcp-server.md](/products/obsidian-license/mcp-server).
* **API keys**: personal, revocable tokens for automating license management or CI-driven plugin uploads.
* **Security**: TOTP-based 2FA on login and an audit log of sensitive actions (revocations, plan changes, admin impersonation).
* **GDPR tooling**: self-service account data export and account deletion.
* **Admin console** (operator only): manage tenants, plans, the feature catalogue and the global audit log.

## Who it's for

Indie and studio developers who sell paid Minecraft (Paper) plugins and want to stop shared and refunded copies, get visibility into where their plugin actually runs, and automate license delivery from the marketplaces they already sell on.


# How It Works

Obsidian License sits between your plugin and your customers. Your plugin asks the platform whether a license is valid; the platform answers in a way that cannot be forged, and logs everything.

## License check

Your plugin ships as an ordinary jar and calls the server on startup.

```
        ┌────────────────────────┐     POST /api/v1/validate     ┌──────────────────────┐
        │  Customer Paper server  │  ──────────────────────────▶  │   License Server     │
        │  your plugin (normal)   │   key + productId + nonce     │  checks key, seats,  │
        │                         │  ◀──────────────────────────  │  expiry, revocation  │
        │  enables, or disables   │   signed yes + tier + limits  │  logs every attempt  │
        └────────────────────────┘                               └──────────────────────┘
```

1. The plugin generates a random nonce and POSTs the key, product id and a machine id, signed with `X-Signature` (HMAC-SHA256 keyed by the license key).
2. The server checks the key: exists, right product, not revoked, not expired, a seat is free for this machine, not throttled, IP not banned.
3. It replies with a yes plus tier and entitlements, Ed25519-signed over a message containing your nonce.
4. The plugin verifies the signature against the pinned public key, then enables itself. Any failure and it disables itself.

Because the nonce is chosen by your plugin and the reply is signed, a recorded response cannot be replayed and a fake server cannot answer "valid".

See [license-check.md](/products/obsidian-license/license-check).

## Protected plugin

The compiled plugin never reaches the customer's disk. They install a thin loader.

```
        ┌────────────────────────┐     POST /api/v1/activate     ┌──────────────────────┐
        │  Customer Paper server  │  ──────────────────────────▶  │   License Server     │
        │  loader-plugin (thin)   │   key + machine fingerprint   │  encrypts jars at    │
        │                         │  ◀──────────────────────────  │  rest, logs access   │
        │  in-memory classloader  │   enc(payload) + key + sig    │                      │
        │  runs the real plugin   │                               │                      │
        └────────────────────────┘                               └──────────────────────┘
```

Same checks, but the response also carries your AES-256-GCM encrypted jar and its key, signed as `nonce.version.payload.key.ent`. The loader verifies, decrypts in memory, and runs the plugin through a custom class loader. Nothing touches disk.

See [protected-plugins.md](/products/obsidian-license/protected-plugins).

## Three hosts, one implementation

Both modes run on Paper, on Velocity, and inside a plain Java application. The activation, signature verification, decryption and in-memory class loading are the same code everywhere; only the lifecycle around them differs (a Bukkit `onEnable`, a Velocity `ProxyInitializeEvent`, or your own `main`). A protected module written against the neutral `ModuleContext` runs on all three unchanged.

## The lifecycle around both

1. **Create a product.** Upload a build if you want the platform to serve it, either encrypted to loaders or as a [direct download](/products/obsidian-license/downloads).
2. **Issue keys**, manually, in bulk, or automatically from a [marketplace purchase](/products/obsidian-license/marketplace-automation).
3. **Servers check in.** Every attempt, allowed or denied, is logged with IP, OS and machine id, and streamed live to your dashboard.
4. **Stay in control.** Revoke a key, let it expire, change a tier, move a customer to the beta channel, free a seat, ban an abusive IP. The change takes effect on the affected servers on their next check.

## Fail closed

Both modes fail closed. If the config is wrong or the license server is unreachable, the plugin does not run. That is deliberate, there is no offline grace period by default. When a check is denied, the customer gets a plain sentence explaining what to do and a link to the self-service portal rather than a stack trace.

## Seats and machine binding

A license runs on a set number of machines. Both modes count seats the same way, keyed on a stable per-server machine id. When a customer migrates hardware they free the old seat themselves at [license.ohalee.com/portal](https://license.ohalee.com/portal) with their key, which removes the most common support ticket a licensed plugin generates.


# License Check

Verify a license from an ordinary plugin jar: one call, a signed answer, seats, expiry, revocation and entitlements.

Your plugin ships normally. On startup it asks the license server one question, "is this license valid right now?", and gets back an answer it can actually trust. No loader, no encrypted payload, no build-time packing, no change to how you write the plugin.

This is the right mode for most sellers. Read [protected-plugins.md](/products/obsidian-license/protected-plugins) if you also need the compiled code to be unavailable without a license.

## What it does and does not do

|                                   | License check   | Protected plugin        |
| --------------------------------- | --------------- | ----------------------- |
| Customer installs                 | Your normal jar | The loader              |
| Your code on disk                 | Yes, in clear   | Never, memory only      |
| Seats and machine binding         | Yes             | Yes                     |
| Revoke / refund kill switch       | Yes             | Yes                     |
| Tiers and entitlements            | Yes, signed     | Yes, signed             |
| Survives someone editing your jar | No              | Nothing on disk to edit |
| Work to adopt                     | One method call | Restructure the plugin  |

Be straight with yourself about the second-to-last row. Someone who can edit your jar can delete the check. What this stops is license sharing, expired use, and refunded-but-kept copies, which is where most sellers actually lose money.

## Setup

Three values, from your dashboard:

```yaml
license:
  # Base URL of the license server.
  serverUrl: "https://license.ohalee.com"

  # The key issued to this customer.
  key: "OBS-XXXX-XXXX-XXXX-XXXX"

  # Dashboard, Server key. Pinning it is what stops a redirected DNS entry or a
  # proxy from answering "valid" on the server's behalf.
  serverPublicKey: "MCowBQYDK2VwAyEA..."
```

## Using it

```java
public final class MyPlugin extends JavaPlugin {

    private ObsidianLicense.Result license;

    @Override
    public void onEnable() {
        saveDefaultConfig();
        var cfg = getConfig();

        var client = new ObsidianLicense(
                cfg.getString("license.serverUrl"),
                cfg.getString("license.serverPublicKey"));

        try {
            license = client.check(
                    cfg.getString("license.key"),
                    "my-plugin",
                    ObsidianLicense.machineId(getDataFolder().toPath()));
        } catch (ObsidianLicense.LicenseException e) {
            getLogger().severe("License check failed: " + e.getMessage());
            getServer().getPluginManager().disablePlugin(this);
            return;
        }

        getLogger().info("License OK" + (license.tier() != null ? " (" + license.tier() + ")" : ""));
    }

    /** Gate a paid feature on an entitlement attached to the license. */
    private boolean canUseAddons() {
        return license != null && license.has("addons");
    }
}
```

`check` throws on every denial and every failure. Catch it, log `e.getMessage()` (it is written for the customer, not for you) and disable the plugin. `e.reason()` gives you the stable machine code if you want to branch on it.

## Why the answer can be trusted

A plain HTTP 200 would be worthless. Anyone can point `serverUrl` at their own machine and reply "yes". Two things prevent that:

1. Your process generates a random **nonce** and sends it. The server signs a message containing that nonce with its Ed25519 private key. A recorded reply from yesterday does not carry today's nonce, and nobody without the private key can produce a signature for it.
2. **Tier and entitlements travel inside that signed message.** So `tier()` and `has(flag)` reflect what you sold, not what the network claimed.

The signed message is exactly:

```
validate.v1.<nonce>.<productId>.<expiresAt>.<entB64>
```

`expiresAt` is ISO-8601, or empty when the license never expires. `entB64` is base64 of `{"tier": ..., "entitlements": {...}}`. The client verifies this before it reads any field.

## Seats

A check binds a seat exactly like a full activation does, keyed on the machine id you pass. `ObsidianLicense.machineId(dataFolder)` writes a random id into your plugin's data folder on first run and reuses it after that, so it survives restarts, differs between servers, and does not break when a host is migrated or containerised.

When a customer moves servers they free the old seat themselves at `https://license.ohalee.com/portal` using their key. That is the single most common support ticket in licensed plugins, and it answers itself.

Need a check that must not consume a seat, such as a health probe or a CI step? Send `bindSeat: false` in the request body.

## Re-checking while the server runs

One check at startup is enough for most plugins. If you want a revocation to bite without waiting for a restart, re-check on a timer:

```java
getServer().getScheduler().runTaskTimerAsynchronously(this, () -> {
    try {
        client.check(key, "my-plugin", machineId);
    } catch (ObsidianLicense.LicenseException e) {
        getServer().getScheduler().runTask(this, () -> {
            getLogger().severe("License no longer valid: " + e.getMessage());
            getServer().getPluginManager().disablePlugin(this);
        });
    }
}, 20L * 3600, 20L * 3600); // hourly
```

Keep the interval generous. Hourly is plenty. A plugin that checks every minute will start hitting the per-license rate limit and get denied for being noisy.

## Denial reasons

| `reason()`               | What the customer should do                        |
| ------------------------ | -------------------------------------------------- |
| `denied_invalid`         | Check the key for typos, copy it again             |
| `denied_product`         | The key is for a different plugin                  |
| `denied_revoked`         | Contact the seller, usually a refund or chargeback |
| `denied_expired`         | Renew                                              |
| `denied_max_activations` | Free a seat at `/portal`, then restart             |
| `denied_throttled`       | Too many attempts, wait a few minutes              |
| `denied_ip_banned`       | Activation from that network is blocked for abuse  |

The exception message already contains a plain sentence for each of these, so printing it is usually all you need.

## The client

The client is one file with no dependencies beyond Gson, which Paper and Velocity already ship. Two ways to get it:

**Copy it.** Grab [`ObsidianLicense.java`](https://github.com/ohAleee/obsidianlicense-api/blob/main/core/src/main/java/com/obsidian/license/core/ObsidianLicense.java) from the public API repository and drop it into your project. Adjust the package and you are done. Nothing to add to your build file.

**Or depend on it.** The same code is published through JitPack:

```kotlin
repositories {
    mavenCentral()
    maven("https://jitpack.io")
}

dependencies {
    implementation("com.github.ohAleee.obsidianlicense-api:core:v1.2.1")
}
```

The artifact targets Java 21 (current Paper requires it). On Java 17 or older, copy the file instead: on its own it needs nothing newer than Java 11.

Everything you need lives in the public repository, so you do not need access to the platform's own source to build against it:

{% embed url="<https://github.com/ohAleee/obsidianlicense-api>" %}

## It works the same everywhere

The client has no Paper or Velocity types in it, so the call above is identical on every host. Only the surrounding lifecycle differs:

| Host       | Where to call it                  | How to fail closed                                                                |
| ---------- | --------------------------------- | --------------------------------------------------------------------------------- |
| Paper      | `onEnable`                        | `getServer().getPluginManager().disablePlugin(this)`                              |
| Velocity   | `@Subscribe ProxyInitializeEvent` | Register nothing and log the reason (Velocity cannot disable a plugin at runtime) |
| Plain Java | Startup, before anything else     | Throw, or `System.exit(1)`                                                        |

Worked examples for all three are in the public repository: [Paper](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/PaperCheck.java), [Velocity](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/VelocityCheck.java), [standalone](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/StandaloneCheck.java).

In a Spring Boot service, run the check from an `ApplicationRunner` or a `@PostConstruct` and throw to abort startup. Spring ships Jackson rather than Gson, so either add the Gson dependency or swap the handful of lines that touch `JsonObject`.


# Protected Plugins

The strong mode: your compiled code never lands on the customer's disk, and runs only in memory after a successful activation. Paper, Velocity, or plain Java.

In this mode customers never receive your jar. They install a thin **loader**. On every start the loader activates the license and the platform returns your **encrypted** code, which the loader decrypts and runs **entirely in memory**.

It is the strongest option and the most invasive one: your code has to implement one interface, and your customers install the loader instead of your plugin. If you only need licensing (seats, expiry, revocation, entitlements), [the license check](/products/obsidian-license/license-check) gets you there with one method call.

## It runs anywhere

There are three hosts, and they share one implementation. The activation, signature verification, decryption and in-memory class loading are the same code in all three; only the surrounding lifecycle differs.

| Host                       | What the customer installs                 | Your module gets                                      |
| -------------------------- | ------------------------------------------ | ----------------------------------------------------- |
| **Paper** (Bukkit, Spigot) | The loader plugin                          | `PluginContext`: listeners, commands, the host plugin |
| **Velocity**               | The loader plugin                          | `VelocityContext`: the proxy and its event manager    |
| **Plain Java**             | Your own application, embedding the loader | `ModuleContext`                                       |

"Plain Java" means exactly that: a Spring Boot service, a CLI tool, a desktop app, a daemon. Anything with a JVM can host a protected module.

## 1. Write the module

Implement `ObsidianModule` and set the `Obsidian-Module` manifest attribute to your entry class. Your class is not a Bukkit or Velocity plugin, so it has no `plugin.yml` and no `@Plugin` annotation.

```java
public final class MyModule implements ObsidianModule {

    private ModuleContext ctx;

    @Override
    public void onEnable(ModuleContext context) {
        this.ctx = context;
        ctx.logger().info("Enabled on " + ctx.platform());

        // Tier and entitlements are identical on every host: they came out of the signed envelope.
        long maxHomes = ctx.entitlementLong("max_homes", 3);
        if (ctx.hasEntitlement("addons")) { /* a paid feature */ }
    }

    @Override
    public void onDisable() { }
}
```

Get the interfaces from the public API repository:

```kotlin
repositories { maven("https://jitpack.io") }
dependencies { compileOnly("com.github.ohAleee.obsidianlicense-api:api:v1.2.1") }
```

{% embed url="<https://github.com/ohAleee/obsidianlicense-api>" %}

### Using platform features

`ModuleContext` mentions no platform types, which is what makes one jar run everywhere. When you need Bukkit or Velocity, take them off the context by pattern matching:

```java
if (context instanceof PluginContext paper) {
    paper.registerListener(new MyListener());
    paper.registerCommand("mycmd", "Does the thing", new MyCommand());
}
```

{% hint style="warning" %}
Keep platform code in its **own class**. A class is only loaded the first time it is used, so as long as your `org.bukkit` imports live in a separate class behind that `instanceof`, the same jar loads cleanly on Velocity and in a plain Java process. Put the Bukkit imports in the module class itself and it stops being portable. See [PortableModule.java](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/PortableModule.java) and [PaperSide.java](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/PaperSide.java).
{% endhint %}

Registrations made through the context are torn down for you on disable. Do not keep static references to module classes: the module runs under a throwaway classloader, and a static reference pins the old one across a reload.

## 2. Create the product and upload

In the [dashboard](https://license.ohalee.com), create a **Product**, then upload the compiled jar. The platform generates an AES-256 key, encrypts the jar at rest, and discards the plaintext. Every upload is retained as a version, so you can roll back or run a beta channel.

A product can hold [several jars](/products/obsidian-license/multiple-jars) under one license, for instance a lobby plugin and a game plugin.

## 3. Issue a license

Create a **License** for the product: expiry, seat limit, and any [tier or entitlements](/products/obsidian-license/tiers-and-entitlements). Issue keys manually, in bulk, or automatically from a marketplace purchase.

## 4. Ship the loader

{% hint style="warning" %}
**Ship your own loader, not the generic one.** A loader hosts exactly one module, and a server refuses two plugins with the same name, so a customer who bought protected plugins from two sellers could only install one generic `ObsidianLoader`. Building your own is about ten lines: see [Ship your own loader](/products/obsidian-license/your-own-loader). The dashboard loader below is for testing and for customers who run only one protected plugin.
{% endhint %}

### Paper

Download the generic loader jar from your dashboard, or build your own. Ship it with a `config.yml`:

```yaml
serverUrl: "https://license.ohalee.com"
licenseKey: "OBS-XXXX-XXXX-XXXX-XXXX"
productId: "my-plugin"
serverPublicKey: "PASTE_THE_KEY_FROM_YOUR_DASHBOARD"
# Only for a product that ships several jars:
# variant: "game"
```

### Velocity

The same, with the Velocity loader jar and `plugins/obsidianloader/config.properties` (a loader you build yourself uses your own plugin id and data directory):

```properties
serverUrl=https://license.ohalee.com
licenseKey=OBS-XXXX-XXXX-XXXX-XXXX
productId=my-plugin
serverPublicKey=PASTE_THE_KEY_FROM_YOUR_DASHBOARD
# variant=lobby
```

Velocity cannot disable a plugin at runtime, so on a failed activation the loader registers nothing and logs why. The module never runs either way.

### Plain Java

There is no loader plugin to ship: you embed the loader in your own application.

```java
ObsidianLoader loader = ObsidianLoader.builder()
        .serverUrl("https://license.ohalee.com")
        .productId("my-service")
        .licenseKey(System.getenv("LICENSE_KEY"))
        .serverPublicKey("MCowBQYDK2VwAyEA...")
        .dataFolder(Path.of("data"))
        // .variant("worker")     only for a product that ships several jars
        .build();

// Fail closed: this throws on any denial, and the module never runs.
ObsidianLoader.LoadedLicense license = loader.start();

Runtime.getRuntime().addShutdownHook(new Thread(loader::stop));
```

See [StandaloneHost.java](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/StandaloneHost.java).

## What happens on activation

1. The loader collects a machine fingerprint plus a fresh random nonce.
2. It POSTs to `/api/v1/activate` with an `X-Signature` header, `base64(HMAC-SHA256(key = licenseKey, msg = raw body))`, proving it holds the key and pinning the exact bytes.
3. The server checks the key: exists, matches the product, not revoked, not expired, a seat is available for this machine, not throttled, IP not banned. Every attempt is logged either way.
4. On success it returns a signed envelope: the AES-256-GCM encrypted jar, the AES key, the version, the signed `ent` block (tier and entitlements), and an Ed25519 signature over `nonce.version.payload.key.ent`.
5. The loader verifies the signature against the pinned `serverPublicKey`, checks the nonce is the one it sent, decrypts in memory, and hands the bytes to a custom class loader.
6. It reads the `Obsidian-Module` manifest attribute, instantiates that class through its public no-arg constructor, and calls `onEnable`.

Because the envelope is signed, a rogue server cannot substitute a malicious payload, and tier and entitlements cannot be edited on the wire.

## Fail closed

No host runs your module if the config is missing or invalid, or if the license server is unreachable. This is deliberate: strict online, no offline grace period. Tell your customers, because a server with no outbound network is a support ticket waiting to happen.

{% hint style="warning" %}
When the platform is updated, download and redistribute the latest loader. An older loader that computes a different signed string fails closed with "response signature invalid".
{% endhint %}

## Upgrading an older module

`ProtectedModule`, whose `onEnable` takes a `PluginContext`, still works. Existing modules keep running with no recompile: the loader detects them and drives them exactly as before.

They are Paper-only, though. To run on Velocity or standalone, switch to `ObsidianModule`:

```java
-public final class MyModule implements ProtectedModule {
-    public void onEnable(PluginContext ctx) {
+public final class MyModule implements ObsidianModule {
+    public void onEnable(ModuleContext ctx) {
```

then move any Bukkit code behind `instanceof PluginContext`, in its own class.

## Honest limits

This raises the bar a long way but is not unbreakable. The JVM belongs to the attacker: a determined party can attach a Java agent or dump the heap and recover the decrypted bytes at runtime. What you durably get is no plaintext jar on disk, a server-side kill switch, per-machine binding, and a full access log. Obfuscating the jar before upload is a worthwhile complement.


# Ship Your Own Loader

Every protected plugin ships its own loader, named after itself. Here is why, and the ten lines it takes.

If you sell a [protected plugin](/products/obsidian-license/protected-plugins), build a loader named after **your** plugin and ship that. Do not ship the generic one.

## Why

A loader hosts exactly one protected module. It has one config file, one product id, one license key, and one module in memory.

Bukkit refuses to enable two plugins with the same name, and Velocity refuses two plugins with the same id. So if every seller shipped the stock `ObsidianLoader`, a customer who bought protected plugins from two different sellers could install exactly one of them. The second would fail to load, and the two would fight over the same `plugins/ObsidianLoader/config.yml` anyway.

Name the loader after your plugin and the problem disappears. Your customers install `MyPlugin`, someone else's install `TheirPlugin`, and both run happily side by side.

The generic loader from the dashboard is still useful: for testing, and for a customer who only ever runs one protected plugin. It is just not what you distribute.

## Get the code

Everything the loader is made of is public:

{% embed url="<https://github.com/ohAleee/obsidianlicense-api>" %}

```kotlin
repositories {
    mavenCentral()
    maven("https://jitpack.io")
    maven("https://repo.papermc.io/repository/maven-public/")
}

dependencies {
    compileOnly("io.papermc.paper:paper-api:1.21.8-R0.1-SNAPSHOT")
    implementation("com.github.ohAleee.obsidianlicense-api:paper:v1.2.1")
}
```

Use `velocity` instead of `paper` for a proxy plugin. You can also just copy the sources into your project if you would rather not add a dependency.

{% hint style="info" %}
**Selling for 1.8?** Use v1.2.1 or newer. Earlier releases reached the command map through `Server#getCommandMap()`, which Paper only added in 1.19.4, so registering a module's commands threw `NoSuchMethodError` on older servers. The loader now resolves it reflectively off the concrete server class, which works on every generation.

Whatever version you use, remember that the `paper` module compiles against a current Paper API. If your loader itself calls a Bukkit method that does not exist on your oldest target, that is a runtime failure on the customer's server, not a compile error on yours. Compiling your loader against your oldest supported API as a build step is the cheapest way to catch it.
{% endhint %}

## The loader, in full

```java
package com.example.myplugin;

import com.obsidian.license.core.ObsidianLoader;
import com.obsidian.license.loader.PaperLoader;
import org.bukkit.plugin.java.JavaPlugin;

public final class MyPluginLoader extends JavaPlugin {

    // You know these three, so hardcode them.
    private static final String SERVER_URL = "https://license.ohalee.com";
    private static final String PRODUCT_ID = "my-plugin";
    private static final String PUBLIC_KEY = "MCowBQYDK2VwAyEA...";   // Dashboard, Server key

    private ObsidianLoader loader;

    @Override
    public void onEnable() {
        saveDefaultConfig();
        try {
            loader = PaperLoader.builder(this)
                    .serverUrl(SERVER_URL)
                    .productId(PRODUCT_ID)
                    .serverPublicKey(PUBLIC_KEY)
                    .licenseKey(getConfig().getString("licenseKey", ""))
                    // .variant("game")   only if your product ships several jars
                    .build();
            loader.start();
        } catch (Exception e) {
            // Fail closed. The message is written for the customer, so print it as-is.
            getLogger().severe("License check failed: " + e.getMessage());
            getServer().getPluginManager().disablePlugin(this);
        }
    }

    @Override
    public void onDisable() {
        if (loader != null) loader.stop();
    }
}
```

`plugin.yml`:

```yaml
name: MyPlugin          # your name, not ObsidianLoader
version: '1.0.0'
main: com.example.myplugin.MyPluginLoader
api-version: '1.21'
load: STARTUP
```

`config.yml`, which is all your customer ever edits:

```yaml
licenseKey: "OBS-XXXX-XXXX-XXXX-XXXX"
```

{% hint style="info" %}
Hardcode the server URL, product id and public key rather than putting them in `config.yml`. The only thing a customer should have to paste is their license key, and a pinned public key they cannot edit is a public key nobody can talk them into changing.
{% endhint %}

Shade the dependency so the loader carries its own classes:

```kotlin
tasks.shadowJar {
    relocate("com.google.gson", "com.example.myplugin.libs.gson")
}
```

## Velocity

The same shape, with `VelocityLoaderSupport` and your own plugin id:

```java
@Plugin(id = "myplugin", name = "MyPlugin", version = "1.0.0")
public final class MyProxyLoader {

    @Subscribe
    public void onInit(ProxyInitializeEvent event) {
        try {
            String key = VelocityLoaderSupport.readConfig(dataFolder).getOrDefault("licenseKey", "");
            loader = VelocityLoaderSupport.builder(this, proxy, dataFolder)
                    .serverUrl(SERVER_URL).productId(PRODUCT_ID).serverPublicKey(PUBLIC_KEY)
                    .licenseKey(key)
                    .build();
            loader.start();
        } catch (Exception e) {
            // Velocity cannot disable a plugin at runtime, so register nothing and say why.
            logger.error("License check failed, this plugin will NOT run: {}", e.getMessage());
        }
    }
}
```

Full template: [MyProxyLoader.java](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/MyProxyLoader.java).

## Plain Java

There is no plugin to name, so there is no collision to avoid: use [`ObsidianLoader`](https://github.com/ohAleee/obsidianlicense-api/blob/main/core/src/main/java/com/obsidian/license/core/ObsidianLoader.java) directly from your own `main`. See [StandaloneHost.java](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/StandaloneHost.java).

## What you ship

Two jars, from two builds:

| Jar                                | Built from          | Goes to                                  |
| ---------------------------------- | ------------------- | ---------------------------------------- |
| `MyPlugin.jar` (the loader)        | Your loader project | Your customers                           |
| `my-plugin-1.0.0.jar` (the module) | Your real plugin    | Uploaded to the dashboard, never shipped |

The module jar is the one that stays secret. The loader is public by nature: it runs on customer machines and can be decompiled by anyone, which is why none of its security depends on being hidden. It pins a public key, and every real check happens on the server.

## Keeping it up to date

Bump the dependency when the platform's signed envelope changes, then rebuild and redistribute your loader. Older loaders fail closed with "response signature invalid" rather than doing anything unsafe, but your customers will notice. Release notes call out any change that needs this.


# Tiers & Entitlements

Sell Basic, Premium and one-off deals from a single build, using values the plugin can trust because they are signed.

Every license can carry a **tier** (a free-form label like `basic` or `premium`) and an **entitlement bag** (any flags and limits you like). Both are delivered inside the signed server response, so your plugin can read them at runtime and trust them.

That means one build, sold several ways. No separate Basic and Premium jars to compile, upload and keep in sync.

## Setting them

On any license, in the dashboard:

* **Tier**: `premium`
* **Entitlements**:

```json
{
  "max_homes": 25,
  "addons": true,
  "support_priority": "24h"
}
```

Change them whenever you like. The new values apply the next time that server checks its license, so an upgrade from Basic to Premium takes effect on the customer's next restart with nothing to redeliver.

## Reading them

With the [license check](/products/obsidian-license/license-check):

```java
ObsidianLicense.Result license = client.check(key, "my-plugin", machineId);

boolean premium = "premium".equals(license.tier());
boolean addons  = license.has("addons");

Object limit = license.entitlements().get("max_homes");
int maxHomes = limit instanceof Number n ? n.intValue() : 3;
```

With a [protected plugin](/products/obsidian-license/protected-plugins), the same values arrive on `PluginContext`:

```java
@Override
public void onEnable(PluginContext ctx) {
    boolean premium = "premium".equals(ctx.tier());
    long maxHomes = ctx.entitlementLong("max_homes", 3);
    if (ctx.hasEntitlement("addons")) {
        // enable the gated feature
    }
}
```

## Why this is safe to gate on

The tier and entitlement block is part of the message the server signs with its Ed25519 key, and your plugin verifies that signature against the public key you pinned in the config. A customer cannot edit the JSON in flight, and a fake server cannot mint a premium answer without the private key.

The usual caveat applies: in license-check mode someone who edits your jar can change what your code does with the answer. Signing guarantees the values are authentic, not that your own code honours them.

## Practical patterns

**Free tier in the same build.** Issue a license with `tier: free` and no entitlements, and gate the paid features. Now the free version and the paid version are one artifact, and an upgrade is a dashboard edit.

**Per-customer limits.** `max_players`, `max_regions`, `max_homes`. A customer who outgrows their plan gets a number changed, not a new download.

**Time-limited add-ons.** Combine an entitlement with the license expiry to sell a season pass on top of a perpetual license.

**Beta features.** Put `beta_ui: true` on a handful of licenses, ship the code to everyone, and enable it only where you want feedback. Pair it with the beta release channel when the change is big enough to need a separate build.


# Several Jars, One License

Ship a lobby plugin and a game plugin under one product and one license key, and let each server pull the jar it needs.

A network plugin is often not one jar. A bedwars product ships a lobby plugin and a game plugin. A survival product might ship a proxy jar and a backend jar. An add-on pack might ship four.

Before, each of those needed its own product and its own key, so a customer who bought "the plugin" ended up holding three unrelated licenses and you answered three support tickets. Now one product can hold several **jars**, one license covers all of them, and each server names the one it wants.

## Naming your jars

A jar name is a short label you choose: `lobby`, `game`, `proxy`, `backend`. Lowercase letters, digits, `.`, `-` and `_`.

Upload one build per jar: **Products**, your product, **Upload jar**, and set **Jar name**. The field suggests the names you have already used, so you do not have to remember how you spelled it last time. Leave it empty for a product that ships a single jar, which is what every existing product does.

Each jar keeps its own versions and its own stable and beta channels. Promoting a `lobby` build never touches `game`.

### Uploading by API

The dashboard field is labelled **Jar name**, but the form field on the API is `variant`, the same word the loader config uses. Send anything else and the upload silently lands as the product's single unnamed jar instead of the one you meant:

```bash
curl -sSf -X POST "https://license.ohalee.com/api/dashboard/products/bedwars/payload" \
  -H "Authorization: Bearer $OBSIDIAN_API_KEY" \
  -F "variant=game" \
  -F "version=1.4.0" \
  -F "channel=stable" \
  -F "jar=@build/libs/bedwars-game-payload.jar"
```

Check the result with `obsidian_list_versions`, or **Products**, your product, **Versions**. Each row shows the variant it was filed under, so a typo is obvious immediately.

## Picking a jar on the customer's side

Paper `config.yml`:

```yaml
serverUrl: "https://license.ohalee.com"
licenseKey: "OBS-XXXX-XXXX-XXXX-XXXX"
productId: "bedwars"
serverPublicKey: "..."
variant: "game"        # this server runs the game jar
```

Velocity `config.properties`:

```properties
productId=bedwars
variant=lobby
```

Plain Java:

```java
ObsidianLoader.builder()
        .productId("bedwars")
        .variant("worker")
        ...
```

License check ([the other mode](/products/obsidian-license/license-check)) does not need this: it verifies a license and does not download anything, so there is no jar to choose.

## What the customer gets

The same license key, on every server. A bedwars customer puts one key in their lobby servers with `variant: lobby`, and the same key in their game servers with `variant: game`.

Seats count across all of it, because seats are per machine, not per jar. A customer running one lobby and three game servers uses four seats. Size the license accordingly.

## How many jars you can ship

Capped per plan, because it is the feature that turns one product into a whole suite:

| Plan               | Jars per product |
| ------------------ | ---------------- |
| Free and mid plans | 1                |
| Top plan           | Unlimited        |

Only a **new** jar name counts against the limit. Re-uploading an existing one is just a new version and is always allowed, whatever your plan.

Operators self-hosting the platform set this per plan themselves, under **Plans**, `maxJarsPerProduct`. `-1` means unlimited.

## If a loader asks for the wrong jar

The denial says what is actually available rather than just failing:

```
this product has no jar called "lobbby". Available: game, lobby
```

And if a product ships several jars but a loader names none:

```
this product ships several jars, set "variant" in your loader config to one of: game, lobby
```

## Existing products keep working

A product that shipped a single jar before this feature keeps serving it to every loader, whether or not the loader knows about jar names. Nothing to migrate and no config to update.

If you later name your only jar something (say you upload `game` and never had a `default`), loaders that name nothing still get it: one jar is unambiguous. It is only ambiguous once you have two.


# Downloading the Jar

Let customers download your jar from the platform, with a valid license as the only key that opens the door.

You can hand out builds through Obsidian License instead of (or alongside) a marketplace download button. The license is the credential: a revoked or expired key stops downloading immediately, and every download lands in your access log.

This is separate from the [protected plugin](/products/obsidian-license/protected-plugins) flow, where the jar is delivered encrypted to a loader and never written to disk. Here the customer receives an ordinary jar file.

## Turn it on

1. **Products**, open the product, **Edit**.
2. Tick **Allow jar download**.
3. Upload a build if you have not already. The current stable version is what gets served (a license on the beta channel gets the beta build).

{% hint style="warning" %}
Leave this off for protected products. Protected products exist so the jar is never on the customer's disk in clear, and switching downloads on would hand out exactly that.
{% endhint %}

## How customers get it

### From the plugin itself

The [license-check client](/products/obsidian-license/license-check) has it built in, which is the neat way to write a self-updater:

```java
byte[] jar = client.download(cfg.getString("license.key"), "my-plugin");
Files.write(getDataFolder().toPath().resolve("update/MyPlugin.jar"), jar);
```

Downloading never consumes a seat, so a customer who re-downloads a build is not burning an activation.

### Directly

`POST https://license.ohalee.com/api/v1/download`, with the same body and the same `X-Signature` header as a license check:

```json
{
  "licenseKey": "OBS-XXXX-XXXX-XXXX-XXXX",
  "productId": "my-plugin",
  "nonce": "<random per request>",
  "fingerprint": {}
}
```

`X-Signature` is `base64(HMAC-SHA256(key = licenseKey, msg = the exact request body bytes))`. That proves the caller holds the key and pins the bytes, so the request cannot be replayed with a different product id.

A success returns the jar as `application/java-archive`, with the version in the `X-Plugin-Version` response header. Anything else returns the usual JSON denial:

```json
{
  "error": "license has expired",
  "reason": "denied_expired",
  "message": "This license has expired. Renew it to keep the plugin running.",
  "helpUrl": "https://license.ohalee.com/portal"
}
```

### Recovering a lost key

A buyer who lost their key does not need you. Point them at:

* `https://license.ohalee.com/redeem` to recover the key attached to their marketplace purchase.
* `https://license.ohalee.com/status` to check whether a key is active.
* `https://license.ohalee.com/portal` to see bound machines and free a seat.

{% hint style="info" %}
A marketplace key is delivered **once** from the redeem page. Marketplace user ids are public, so a key that stayed retrievable forever would be retrievable by anyone who can count. If a buyer says the page tells them the key was already redeemed and they never received it, reissue the key from your dashboard.
{% endhint %}

## Publishing a new build

Upload a new version to the product and every download from that moment serves it. Nothing to expire or invalidate, and no links to rotate.

Uploads are retained as versions, so you can promote a build to **beta** for trusted customers first and roll back instantly if it goes badly. Licenses on the stable channel keep getting the stable build throughout.

For CI, generate a personal **API key** in the dashboard and upload the freshly built jar as part of your release pipeline. See [for-developers.md](/products/obsidian-license/for-developers).


# Configuration

There is nothing to configure on the platform side: products, licenses, seats, expiry, tiers, channels, trials, branding, webhooks and API keys all live in your dashboard at [license.ohalee.com](https://license.ohalee.com).

The only file you configure is the one your customers get. Which file that is depends on your mode.

## License check: your own `config.yml`

Your plugin owns its config, so the shape is up to you. Three values need to reach the client:

```yaml
license:
  # Base URL of the license server.
  serverUrl: "https://license.ohalee.com"

  # The key issued to this customer. The only value that changes per customer.
  key: "OBS-XXXX-XXXX-XXXX-XXXX"

  # Dashboard, Server key. Pinning it stops a redirected DNS entry or a proxy
  # from answering "valid" on the server's behalf.
  serverPublicKey: "PASTE_THE_KEY_FROM_YOUR_DASHBOARD"
```

Ship `serverUrl` and `serverPublicKey` pre-filled in your default config. Asking a customer to paste a public key is asking for a support ticket.

## Protected plugin, Paper: `plugins/ObsidianLoader/config.yml`

Download the loader from your dashboard and set four values. The loader fails closed if anything is missing or invalid, or if the server is unreachable.

```yaml
# ObsidianLoader configuration

# Base URL of the license server.
serverUrl: "https://license.ohalee.com"

# The license key issued to this customer from your dashboard.
licenseKey: "OBS-XXXX-XXXX-XXXX-XXXX"

# Which product this loader should fetch. Shown in your dashboard per product.
productId: "my-plugin"

# The server's Ed25519 public key, shown in your dashboard.
# Pinning this prevents a rogue server from substituting a malicious payload.
serverPublicKey: "PASTE_SERVER_PUBLIC_KEY_HERE"
```

Add `variant: "game"` only if the product ships [several jars](/products/obsidian-license/multiple-jars).

## Protected plugin, Velocity: `plugins/obsidianloader/config.properties`

The same four values, in Velocity's plugin data directory:

```properties
serverUrl=https://license.ohalee.com
licenseKey=OBS-XXXX-XXXX-XXXX-XXXX
productId=my-plugin
serverPublicKey=PASTE_SERVER_PUBLIC_KEY_HERE
# variant=lobby
```

## Protected module, plain Java

No config file: you pass the values to the loader yourself, so read them from wherever your application already reads configuration (environment, properties, Spring config).

```java
ObsidianLoader.builder()
        .serverUrl("https://license.ohalee.com")
        .productId("my-service")
        .licenseKey(System.getenv("LICENSE_KEY"))
        .serverPublicKey(System.getenv("LICENSE_PUBLIC_KEY"))
        .dataFolder(Path.of("data"))
        .build();
```

## Where each value comes from

| Value                | Where to find it                                           |
| -------------------- | ---------------------------------------------------------- |
| `serverUrl`          | Always `https://license.ohalee.com` for the hosted service |
| `licenseKey` / `key` | The key you issued, or the buyer redeemed. Licenses page   |
| `productId`          | Your product's id. Products page                           |
| `serverPublicKey`    | Dashboard, Server key. The same key for all your products  |

{% hint style="info" %}
When you distribute to a customer, the only value that changes per customer is the license key. Everything else is identical for everyone who buys that product.
{% endhint %}

## Self-hosting

If you run your own instance, the server is configured through environment variables: Postgres URL, `JWT_SECRET`, `MASTER_KEY`, SMTP, abuse thresholds, retention, and the `LEGAL_*` identity used on the customer-facing pages. `serverUrl` and `serverPublicKey` then come from your own deployment rather than the hosted values above. That operator setup is outside the scope of these customer-facing docs, contact support if you self-host.


# Marketplace Automation

You don't have to create a license per sale. Each developer connects their **own** marketplace integration from the dashboard - there is no shared/global secret. A purchase auto-issues a key; a refund/chargeback auto-revokes it.

## BuiltByBit

1. **Map the product** - set its **BuiltByBit resource ID** on the product (Products dialog).
2. **Connect** - open **Integrations → Connect BuiltByBit**. This generates your private **webhook URL** and **secret** (both resettable). Requires a plan with marketplace integrations enabled.
3. **Create the webhook on BuiltByBit** (Webhooks → Create):
   * Trigger **Purchase**, Template **Custom**, Method **POST**, Content type **application/json**.
   * URL = your **webhook URL** - the exact per-tenant URL is shown in your dashboard (`https://license.ohalee.com/api/v1/webhook/builtbybit/<your-id>`).
   * Body - your **secret** goes in the `secret` field:

     ```json
     {
       "event": "purchase",
       "resourceId": "{resource_id}",
       "userId": "{user_id}",
       "buyerName": "{username}",
       "secret": "<your secret>"
     }
     ```

The server auto-creates the license (idempotent per buyer + product, scoped to your products).

{% hint style="info" %}
BuiltByBit has no purchase-id placeholder, so a sale is identified by `{user_id}` + `{resource_id}`.
{% endhint %}

## Voxel (formerly Polymart)

**Voxel** (voxel.shop) is supported the same way from the **Integrations** page - connect it, map the product, and create the webhook.

## Custom webhook

A generic custom-webhook platform covers anywhere else you sell.

## Delivering the key to buyers

Link buyers to **`https://license.ohalee.com/redeem`** from your resource's purchase instructions - they enter their **BuiltByBit user ID** (from their profile URL) and get their key to paste into `config.yml`.

> Set an **Ultimate API token** on the Integrations page and the server will DM the key to the buyer automatically (no redeem step).

## Customer-facing pages

Besides `/redeem`, buyers have:

| Page                  | What it does                                                           |
| --------------------- | ---------------------------------------------------------------------- |
| `/redeem`             | Enter marketplace user ID → get the license key.                       |
| `/status`             | License status by key.                                                 |
| `/portal`             | Status + free a seat themselves when migrating servers (rate-limited). |
| `/trial?product=<id>` | Claim a self-serve trial when a product has trials enabled.            |

All four pick up your **custom branding** (name, accent, logo, support link) from the Account page.


# Security Model

## An honest note

Neither mode is unbreakable, and the limit is different in each.

**License check.** Your jar is on the customer's disk, so someone who edits it can delete the check. What it reliably stops is license sharing, expired use, and refunded copies that keep running.

**Protected plugin.** There is no jar on disk, but the JVM still belongs to the attacker: a determined party can attach a Java agent or dump heap memory and recover the decrypted bytes at runtime.

Set expectations accordingly. The durable value in both cases is:

1. **Signed answers.** Every reply is Ed25519-signed over a nonce your plugin chose, so a fake server or a replayed response cannot grant a license.
2. **No plaintext jar on disk** (protected mode), which defeats casual decompilation and redistribution.
3. A server-side **kill switch**, cutting off any copy on its next check.
4. **Per-machine binding**, so one key cannot silently run everywhere.
5. Full **access logging** (IP, OS, machine id) to detect and investigate abuse.

Obfuscation (e.g. ProGuard) of the protected jar and anti-agent hardening are complementary follow-ups, intentionally out of scope for the first version.

## What protects a copy in the wild

| Layer           | Mechanism                                                                                                                                                                 |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| At rest         | Protected jars are encrypted **AES-256-GCM**; the plaintext is discarded on upload.                                                                                       |
| In transit      | Activation responses are **Ed25519-signed** (`nonce.version.payload.key.ent`), so a rogue server can't substitute a payload and tier/entitlements can't be tampered with. |
| On the customer | The loader decrypts and class-loads **in memory** - nothing touches disk.                                                                                                 |
| Binding         | Each license is bound to a configurable number of machine fingerprints.                                                                                                   |
| Revocation      | Revoke / expire / rebind from the dashboard; the plugin stops loading on affected servers at next boot.                                                                   |
| Abuse           | Per-IP auto-ban and per-license rate-limiting blunt sharing and brute force.                                                                                              |

## Your account security

On the hosted service, the encryption keys and infrastructure are managed for you. What you control on your account:

* **Account security** - TOTP-based **2FA** on login, plus an **audit log** of sensitive actions (revocations, plan changes, etc.).
* **API keys** - personal, revocable tokens for automating license management or CI-driven uploads; revoke any that leak.
* **GDPR** - self-service account data export and account deletion are built in.

{% hint style="info" %}
If you self-host, you additionally own the **Ed25519 signing keys** and the `MASTER_KEY` that wraps per-product AES keys at rest - back both up, because losing `MASTER_KEY` makes stored product keys unrecoverable. On the hosted service this is handled for you.
{% endhint %}

## Fail-closed behaviour

The loader is strict-online and fails closed: if the config is missing/invalid or the server is unreachable, the protected plugin does **not** run. There is no offline grace mode by default.


# For Developers

{% hint style="info" %}
This page covers the **protected plugin** mode, where your code runs in memory inside a loader. If you just want license enforcement in an ordinary jar, you want [license-check.md](/products/obsidian-license/license-check) instead, which is one method call.
{% endhint %}

To protect a plugin you implement the `ProtectedModule` contract from the `protected-api` module. Your plugin is **not** loaded by Bukkit's plugin manager - the thin loader downloads the encrypted jar, decrypts it in memory, finds the class named by the `Obsidian-Module` manifest attribute, instantiates it via its public no-arg constructor, and drives its lifecycle.

## The contract

```java
public interface ProtectedModule {

    /**
     * Called once after the license has been validated and the module loaded
     * into memory.
     *
     * @param context bridge to the host JavaPlugin (events, commands, logger, config)
     */
    void onEnable(PluginContext context);

    /**
     * Called when the host plugin is disabled (server stop / reload). Clean up
     * here; registered listeners/commands are also torn down automatically.
     */
    void onDisable();
}
```

{% hint style="warning" %}
Don't keep static references that would survive a reload - the module is loaded by a custom in-memory class loader that gets torn down and recreated.
{% endhint %}

## PluginContext

The loader hands your module a `PluginContext` so it can integrate with the running server without being a Bukkit plugin itself.

```java
public interface PluginContext {
    JavaPlugin plugin();                 // the host loader (schedulers, config folder, ...)
    Logger logger();                     // namespaced to the module
    String licenseKey();                 // the activating server's license key

    String tier();                       // free-form SKU label, or null
    Map<String, Object> entitlements();  // per-license flags/limits, never null

    // convenience readers over entitlements()
    default boolean hasEntitlement(String key);
    default long entitlementLong(String key, long fallback);

    void registerListener(Listener listener);                 // auto-unregistered on disable
    void registerCommand(String name, String description, CommandExecutor executor);
    void registerCommand(String name, String description, CommandExecutor executor, TabCompleter completer);
}
```

`registerCommand` registers a command that does **not** exist in any `plugin.yml` via Bukkit's `CommandMap`, and unregisters it on disable.

### Tier & entitlements

`tier()` and `entitlements()` are delivered **inside the signed activation envelope**, so they're tamper-proof. This is how one product sells Basic vs Premium with no separate builds - the module reads them at runtime:

```java
@Override
public void onEnable(PluginContext ctx) {
    boolean premium = "premium".equals(ctx.tier());
    long maxHomes = ctx.entitlementLong("max_homes", 3);
    if (ctx.hasEntitlement("pvp_toggle")) {
        // enable a gated feature
    }
    ctx.registerListener(new MyListener());
    ctx.registerCommand("mycmd", "Does the thing", new MyCommand());
}
```

## Classloader boundary

`PluginContext` and `ProtectedModule` live in `protected-api`, which is bundled into the **loader** and owned by the loader's classloader. Protected modules reference `protected-api` as `compileOnly` and resolve it at runtime via parent delegation - so casts across the loader/module boundary stay valid. Don't shade `protected-api` into your module jar.

## Packaging and uploading

Build your plugin the way you normally would (a standard Paper jar) - just make it implement `ProtectedModule` and set the `Obsidian-Module` manifest attribute to your entry class. Then **upload the jar** to your product in the dashboard; the platform encrypts it and serves it to loaders. You do **not** build the loader yourself - download it from the dashboard and ship it to customers.

## Automating uploads from CI

Generate a personal **API key** in the dashboard and use it to upload a freshly built jar as a new product version from your CI pipeline. New uploads are retained as versions, so you can put trusted customers on a **beta** channel and promote or roll back instantly.

## Let an AI agent do it

The platform ships an MCP server that hands a coding assistant these same rules as guides, plus tools to create the product, upload the jar, and issue keys. See [AI Agent (MCP)](/products/obsidian-license/mcp-server).


# AI Agent (MCP)

Connect an AI coding assistant to your Obsidian License account over the Model Context Protocol, at https\://license.ohalee.com/mcp.

Our [Model Context Protocol](https://modelcontextprotocol.io) endpoint lets an AI coding assistant (Claude Code, or any MCP client) work with your licensing directly. It gives the agent two things:

1. **A way in.** Manage products, issue and revoke license keys, promote and roll back versions, and read activation logs, as the account behind your API key.
2. **The knowledge to use it.** Ten implementation guides plus a project scaffolder covering both modes end to end: the one-call license check for an ordinary jar, and the full protected-module route with the `ProtectedModule` contract, the `Obsidian-Module` manifest attribute, the classloader rules, tiers and entitlements, the loader, publishing, and the wire protocol.

The knowledge half needs no credentials, so the agent can learn the whole mechanism before it touches your account.

## Setup

Nothing to install. We serve MCP at **`https://license.ohalee.com/mcp`**, and there are two ways to authenticate.

### Sign in from your client (recommended)

Add the endpoint with no credentials. Your client opens a browser, you sign in, and it keeps itself authenticated from there.

```bash
claude mcp add --transport http obsidian-license https://license.ohalee.com/mcp
```

We implement the MCP authorization spec end to end: protected-resource and authorization-server discovery, dynamic client registration, and an authorization-code flow with PKCE. Your client handles the whole dance, so all you do is approve the request in the browser. Access tokens last 12 hours and are refreshed automatically, so you are not pasting anything again tomorrow.

{% hint style="info" %}
If your client reports **"Dynamic Client Registration rejected"** or **"Cannot POST /register"**, it is talking to a build from before OAuth support landed. Update the server (self-hosters) or reconnect, and make sure the URL is exactly `https://license.ohalee.com/mcp` with no trailing path.
{% endhint %}

### Or use an API key

Better for CI, headless machines, and anywhere a browser is not available. Create one on the API Keys page and send it as a bearer token:

```bash
claude mcp add --transport http obsidian-license https://license.ohalee.com/mcp \
  --header "Authorization: Bearer obl_your_key"
```

Or in a client that reads a JSON config:

```json
{
  "mcpServers": {
    "obsidian-license": {
      "type": "http",
      "url": "https://license.ohalee.com/mcp",
      "headers": { "Authorization": "Bearer obl_your_key" }
    }
  }
}
```

However you authenticate, the endpoint acts as your account, with the same permissions, plan limits and audit trail as the dashboard. It is stateless: each request gets its own server scoped to the token it carried, so nothing is kept between calls and nothing is shared between accounts. Send JSON-RPC with `POST`; a `GET` returns 405, because there is no session to stream into.

{% hint style="warning" %}
An API key carries your account's permissions. Treat it like a password, keep it out of your repository, and revoke it from the API Keys page (or with `obsidian_revoke_api_key`) if it leaks.
{% endhint %}

## The local option

The platform also ships a **stdio** MCP server in the `mcp-server/` package, which runs on your own machine. It has the same license tools and the same guides, plus the three things the hosted endpoint cannot do, since that one runs on our servers and cannot see your disk:

* **Upload a built jar** from a local path, with a check that the jar really carries its `Obsidian-Module` manifest attribute.
* **Write a generated starter project** straight into a folder.
* **Sign in with your email and password** instead of carrying an API key. The agent calls `obsidian_login` and asks you for a TOTP code when two-factor is on; the password is used once and never stored, and the 12 hour token is cached with `0600` permissions.

```bash
cd mcp-server
npm install && npm run build
claude mcp add obsidian-license \
  --env OBSIDIAN_URL=https://license.ohalee.com \
  --env OBSIDIAN_API_KEY=obl_your_key \
  -- node /absolute/path/to/mcp-server/dist/index.js
```

| Variable                 | Meaning                                                                  |
| ------------------------ | ------------------------------------------------------------------------ |
| `OBSIDIAN_URL`           | Your license server, `https://license.ohalee.com` for the hosted service |
| `OBSIDIAN_API_KEY`       | Personal API key (`obl_...`), optional                                   |
| `OBSIDIAN_MCP_NO_CACHE`  | `1` keeps a login token in memory only                                   |
| `OBSIDIAN_MCP_STATE_DIR` | Where that token is cached (default `~/.obsidian-license/`)              |

There is deliberately no default server URL: leaving it unset is safer than guessing one and sending your password somewhere you did not choose.

## What the agent can do

**Session:** `obsidian_login`, `obsidian_logout`, `obsidian_whoami`, `obsidian_set_server`, `obsidian_create_api_key`, `obsidian_list_api_keys`, `obsidian_revoke_api_key`

**Products:** `obsidian_list_products`, `obsidian_create_product`, `obsidian_update_product`, `obsidian_upload_payload`, `obsidian_list_versions`, `obsidian_promote_version`, `obsidian_set_product_trial`, `obsidian_delete_product`, `obsidian_server_key`

**Licenses:** `obsidian_list_licenses`, `obsidian_create_license`, `obsidian_bulk_create_licenses`, `obsidian_update_license`, `obsidian_revoke_license`, `obsidian_reactivate_license`, `obsidian_reset_license_activations`, `obsidian_delete_license`

**Diagnostics:** `obsidian_activation_logs`, `obsidian_stats`

**Knowledge:** `obsidian_guide`, `obsidian_scaffold_java`

The hosted endpoint carries all of these except the ones that need your filesystem or an interactive login: `obsidian_login`, `obsidian_logout`, `obsidian_set_server` and `obsidian_upload_payload` are stdio only, and `obsidian_scaffold_java` returns the files as text rather than writing them.

`obsidian_upload_payload` checks the jar's `Obsidian-Module` manifest attribute before uploading anything: a jar without it uploads happily and then fails on every customer's server.

## The guides

`obsidian_guide` returns the topic below as markdown. They are also exposed as resources at `obsidian://guide/<slug>`, and there are two prompts, `protect-java-plugin` and `diagnose-activation`.

| Topic              | Covers                                                                   |
| ------------------ | ------------------------------------------------------------------------ |
| `overview`         | The moving parts and what happens on a customer's server                 |
| `license-check`    | Licensing an ordinary jar with one call: the client, seats, entitlements |
| `java-integration` | Gradle setup, the entry class, the manifest attribute, classloader rules |
| `migrating-plugin` | Translating an existing `JavaPlugin`, and the state that breaks reloads  |
| `entitlements`     | Selling several SKUs from one build with the signed entitlement block    |
| `loader-config`    | Building the loader and what customers install                           |
| `publishing`       | Products, uploads, version channels, CI                                  |
| `licensing`        | Seats, revocation, marketplaces, trials, spotting abuse                  |
| `protocol`         | The wire format, the signed envelope, every denial code                  |
| `troubleshooting`  | What each failure actually means                                         |

## Things to ask it

* "Convert this plugin to a protected module and publish it as `my-plugin`."
* "A customer says the plugin will not start. Their key is OBS-... Work out why."
* "Issue 50 keys for `my-plugin` with 2 seats each and give me the CSV."
* "Put this build on the beta channel and move my three testers onto it."
* "Add a premium tier to `my-plugin` with a `max_homes` entitlement, and show me the code that reads it."

{% hint style="info" %}
The agent is acting on your live account. Ask it to confirm before it revokes, deletes, or promotes anything, and check what it proposes on destructive calls.
{% endhint %}


# PlayerDataHistory

PlayerDataHistory is a plugin that allows you to memorize every player action within your network.

{% content-ref url="/pages/pheAr7iwcH4hM7kq11I2" %}
[Features](/products/playerdatahistory/features)
{% endcontent-ref %}

{% content-ref url="/pages/T4NwUEg2ivhDssEpu1l5" %}
[Installation](/products/playerdatahistory/installation)
{% endcontent-ref %}

{% content-ref url="/pages/N6qfp5BqrZL7z2eVxG3A" %}
[Configuration](/products/playerdatahistory/configuration)
{% endcontent-ref %}

{% content-ref url="/pages/09wlItU8vLKj41GXvLDq" %}
[Web Panel](/products/playerdatahistory/web-panel)
{% endcontent-ref %}

{% content-ref url="/pages/vWZcOCwx93n9XDadJypr" %}
[Command & Permissions](/products/playerdatahistory/command-and-permissions)
{% endcontent-ref %}

{% content-ref url="/pages/5FYnuGmG8rzIrR1X9sxb" %}
[For Developers](/products/playerdatahistory/for-developers)
{% endcontent-ref %}


# Features

PlayerDataHistory records what players do across your server or network and lets you review it in-game or from the web panel.

## Action Logging

Every relevant player action is captured and stored:

* **Chat messages** — with the server they were sent on.
* **Commands** — with configurable ignore rules (`ignored-commands`) so sensitive commands like `/login` are never stored.
* **Server switches** — which server a player moved to and when (network-wide on Velocity).
* **Sessions** — join and leave times.

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-98b95dda479d278a5acd6c7eb698e34f6d7b8da0%2Fimage_2024-11-28_12-54-24.png?alt=media" alt=""><figcaption></figcaption></figure>

## Buffered, Asynchronous Writes

Actions are buffered and flushed to the database on an interval (`entry.flush-interval`) to keep the main thread free. You can force an immediate flush at any time with `/dh push`.

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-da3618b9de2eda64e3a9092c0b608fd3a6bfa594%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Verbose Mode

Watch actions live as they happen with `/dh verbose`, optionally narrowed with a filter expression.

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-a0fc288f0a85c118af3feb04626bbb200e031a48%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Web Panel

A companion web panel lets staff browse players, sessions and server switches from a browser. Access is granted per-user with an OTP via `/dh register`. See [web-panel.md](/products/playerdatahistory/web-panel).

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-6e7c80746414cf769fa7145543e50d2024cc43ad%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Multi-Platform

Runs on **Bukkit/Spigot/Paper** and on **Velocity** proxies, sharing a single MariaDB database so the whole network's history lives in one place.


# Installation

It's plug and play — add the jar to the server `plugins` folder and start the server.

{% hint style="info" %}
By default the plugin stores data in an embedded **H2** database, so no extra setup is required. To use a remote **MariaDB** database, set `storage-method: mariadb` and fill in the `data` section of `config.yml`.
{% endhint %}

## Bukkit / Spigot / Paper

* Drop the `PlayerDataHistory` jar into `plugins/` and start the server.
* Set a unique `server-name` in `config.yml` so server-switch logs identify this server correctly.

## Velocity

* A separate **Velocity** build is available for proxy networks (command `/dhv`). Install it on the proxy to record cross-server switches and sessions for the whole network.

{% hint style="warning" %}
Point every server/proxy at the **same MariaDB database** so all of them read and write the same history. H2 is per-instance and cannot be shared across a network.
{% endhint %}


# Configuration

```yaml
#  Remote databases
#   - MariaDB
#
#  Don't require any extra configuration
#   - H2
storage-method: h2

entry:
  flush-interval: 30 # (in seconds)
  flush-min-size: 0
  console-log: false

# A list of commands that should be ignored by the plugin.
ignored-commands:
  - "register"
  - "login"
  - "setblock"
  - "minecraft:setblock"
  - "fill"
  - "minecraft:fill"

# ONLY FOR SPIGOT, PAPER, BUKKIT
server-name: 'survival-1'

# The following block defines the settings for remote database storage methods.
data:
  address: localhost
  database: minecraft
  username: root
  password: ''

  pool-settings:
    maximum-pool-size: 5
    minimum-idle: 5
    maximum-lifetime: 1800000 # 30 minutes
    keepalive-time: 0
    connection-timeout: 5000 # 5 seconds
    properties:
      useUnicode: true
      characterEncoding: utf8

  table-prefix: 'datahistory_'
```

## Key reference

<table><thead><tr><th width="230">Key</th><th>Description</th></tr></thead><tbody><tr><td>storage-method</td><td><code>h2</code> (embedded, no setup) or <code>mariadb</code> (remote)</td></tr><tr><td>entry.flush-interval</td><td>How often buffered actions are written to the database, in seconds</td></tr><tr><td>entry.flush-min-size</td><td>Minimum number of buffered actions before a flush occurs</td></tr><tr><td>entry.console-log</td><td>Whether to also log actions to the server console</td></tr><tr><td>ignored-commands</td><td>Commands that are never logged (useful for hiding <code>/login</code>, <code>/register</code>, etc.)</td></tr><tr><td>server-name</td><td>The name reported for this server in server-switch logs. <strong>Bukkit/Spigot/Paper only</strong></td></tr><tr><td>data.*</td><td>Address, database, credentials and connection-pool settings used by MariaDB</td></tr><tr><td>data.table-prefix</td><td>Prefix applied to every database table</td></tr></tbody></table>

{% hint style="warning" %}
`storage-method`, the `data.*` connection settings, `data.table-prefix` and `entry.flush-interval` are **not** reloadable — changes to them only apply after a full server restart.
{% endhint %}


# Web Panel

### Overview

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-cb0456a85639fd6b675eef0a45c3143898da5731%2Fimage%201.png?alt=media" alt=""><figcaption><p>Login</p></figcaption></figure>

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-a12f9fc67e9ecd8556d4c118488a542e349cbb08%2Fimage%207.png?alt=media" alt=""><figcaption><p>Register</p></figcaption></figure>

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-97af5c232054a9230e8be5f5cddc3ea208ca83e9%2Fimage%202.png?alt=media" alt=""><figcaption><p>Dashboard</p></figcaption></figure>

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-13c3402e34b1e86ce8c38387fa7ad1958ff51315%2Fimage%203.png?alt=media" alt=""><figcaption><p>Player List</p></figcaption></figure>

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-71165fa025bd247237dca05a469dcb3169cc4425%2Fimage%204.png?alt=media" alt=""><figcaption><p>Server Switches</p></figcaption></figure>

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-ed73abea907a8ad5a393f040d872c40e53372d2b%2Fimage%205.png?alt=media" alt=""><figcaption><p>Sessions</p></figcaption></figure>

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-59c1bf768be105236f92e30d512b2728dca51afd%2Fimage%206.png?alt=media" alt=""><figcaption><p>Per Player Info... and more</p></figcaption></figure>


# Command & Permissions

On Bukkit/Paper the command is `/datahistory`, with the aliases `/dh` and `/dhbukkit`. On a Velocity proxy the command is `/dhv` (aliases `/dhvelocity`). All examples below use `/dh`.

{% hint style="info" %}
The `chat`, `commands`, `servers` and `sessions` log views are sub-commands of `user` and accept an optional `[page]` argument. `register` and `unregister` can only be run from the console.
{% endhint %}

<table><thead><tr><th width="270">Command</th><th width="120">Parameters</th><th width="230">Permission</th><th>Capabilities</th></tr></thead><tbody><tr><td>/dh user &#x3C;user> info</td><td></td><td>datahistory.user.info</td><td>Show user info (UUID, status, last IP)</td></tr><tr><td>/dh user &#x3C;user> chat</td><td>[page]</td><td>datahistory.log.chat</td><td>View chat history</td></tr><tr><td>/dh user &#x3C;user> commands</td><td>[page]</td><td>datahistory.log.commands</td><td>View command history</td></tr><tr><td>/dh user &#x3C;user> servers</td><td>[page]</td><td>datahistory.log.servers</td><td>View server-switch history</td></tr><tr><td>/dh user &#x3C;user> sessions</td><td>[page]</td><td>datahistory.log.sessions</td><td>View session (join/leave) history</td></tr><tr><td>/dh user &#x3C;user> purge</td><td></td><td>datahistory.user.purge</td><td>Delete all stored data for a user</td></tr><tr><td>/dh verbose</td><td>&#x3C;on|record|off|upload> [filter]</td><td>datahistory.verbose</td><td>Live verbose logging of actions</td></tr><tr><td>/dh push</td><td></td><td>datahistory.push</td><td>Flush buffered entries to the database now</td></tr><tr><td>/dh reloadconfig</td><td></td><td>datahistory.reloadconfig</td><td>Reload the configuration</td></tr><tr><td>/dh register</td><td>&#x3C;otp></td><td>datahistory.web.register</td><td>Register a web-panel user (console only)</td></tr><tr><td>/dh unregister</td><td>&#x3C;username></td><td>datahistory.web.unregister</td><td>Remove a web-panel user (console only)</td></tr></tbody></table>

## Additional Permissions

<table><thead><tr><th width="270">Permission</th><th>Capabilities</th></tr></thead><tbody><tr><td>datahistory.verbose.join</td><td>Automatically receive verbose output on join</td></tr></tbody></table>


# For Developers

{% hint style="info" %}
PlayerDataHistory does not ship a public API artifact or service provider. This page documents its architecture and storage schema so you can integrate by **reading the database** directly.
{% endhint %}

## Architecture

The plugin is split into platform-independent and platform-specific modules:

* **common** — the shared core: command framework, storage layer, action logging, configuration and localisation.
* **bukkit** / **bukkit-loader** — the Bukkit/Spigot/Paper platform and its jar-in-jar loader.
* **velocity** — the Velocity proxy platform.

The core is built on a LuckPerms-style foundation: a **JarInJar loader** unpacks the plugin and downloads its runtime dependencies on first start, a `Storage` abstraction sits over the SQL implementations (H2 and MariaDB via HikariCP), and buffered actions are flushed to storage on the configured interval.

## Storage Schema

All tables use the configured `data.table-prefix` (default `datahistory_`) in place of `{prefix}`. Every log table references `players(id)` by `user_id`.

### `{prefix}players`

| Column     | Type        | Notes               |
| ---------- | ----------- | ------------------- |
| `id`       | INT (PK)    | Internal user id    |
| `username` | VARCHAR(16) | Last known username |
| `uuid`     | VARCHAR(36) | Unique              |
| `date`     | DATETIME    | First seen          |

### `{prefix}sessions`

| Column         | Type        | Notes                          |
| -------------- | ----------- | ------------------------------ |
| `id`           | INT (PK)    |                                |
| `user_id`      | INT (FK)    | → `players.id`                 |
| `ip`           | VARCHAR(32) | Connecting IP                  |
| `connect`      | DATETIME    | Join time                      |
| `disconnect`   | DATETIME    | Leave time (null while online) |
| `playtime`     | BIGINT      | Session playtime               |
| `client_brand` | VARCHAR(64) | Client brand, if known         |
| `version`      | INT         | Protocol version               |

### `{prefix}servers`

| Column    | Type        | Notes                    |
| --------- | ----------- | ------------------------ |
| `user_id` | INT (FK)    | → `players.id`           |
| `depart`  | VARCHAR(32) | Server left              |
| `arrive`  | VARCHAR(32) | Server joined            |
| `date`    | DATETIME    | When the switch happened |

### `{prefix}commands`

| Column    | Type         | Notes            |
| --------- | ------------ | ---------------- |
| `user_id` | INT (FK)     | → `players.id`   |
| `command` | VARCHAR(512) | Command executed |
| `server`  | VARCHAR(64)  | Origin server    |
| `date`    | DATETIME     |                  |

### `{prefix}messages`

| Column    | Type         | Notes          |
| --------- | ------------ | -------------- |
| `user_id` | INT (FK)     | → `players.id` |
| `message` | VARCHAR(512) | Chat message   |
| `server`  | VARCHAR(64)  | Origin server  |
| `date`    | DATETIME     |                |

### `datahistory_web_users`

Stores web-panel accounts created with `/dh register` (`username`, hashed `password`).


# Modern OneBlock

Modern OneBlock takes inspiration from SkyBlock to create an innovative mode. 1 block I will bring a huge island to life. Different stages to go through and different objectives to complete.

{% hint style="info" %}
UltimateOneBlock is now **Modern OneBlock**. The plugin targets **Minecraft 26.1+**, runs on **Paper and Folia**, and uses year-based version numbers (`26.1.0`).

Coming from 1.0.8? Read [upgrading.md](/products/modern-oneblock/upgrading) first.
{% endhint %}

{% content-ref url="/pages/D2mJJmlQ4xIXiQquvkBX" %}
[Features](/products/modern-oneblock/features)
{% endcontent-ref %}

{% content-ref url="/pages/zAvZiqm4Ouw51U7up2RM" %}
[Installation](/products/modern-oneblock/installation)
{% endcontent-ref %}

{% content-ref url="/pages/zFIy4TdM7M07z5R1Z0y2" %}
[Upgrading from 1.0.8](/products/modern-oneblock/upgrading)
{% endcontent-ref %}

{% content-ref url="/pages/nnpvMKkJtc2dE5gW1TiH" %}
[Configuration](/products/modern-oneblock/configuration)
{% endcontent-ref %}

{% content-ref url="/pages/vbOokflLBQW7BEXWezP5" %}
[Phase](/products/modern-oneblock/phase)
{% endcontent-ref %}

{% content-ref url="/pages/4mrxgYqj7uWUGDvkctRY" %}
[Generators](/products/modern-oneblock/generators)
{% endcontent-ref %}

{% content-ref url="/pages/HKohPCo9hLCPAFoT3ZzI" %}
[Placeholders](/products/modern-oneblock/placeholders)
{% endcontent-ref %}

{% content-ref url="/pages/x2S9O9WtwNfy9bslhCp2" %}
[Command & Permissions](/products/modern-oneblock/command-and-permissions)
{% endcontent-ref %}

{% content-ref url="/pages/VPZJ6a96JoW5dT2kFAGo" %}
[For Developers](/products/modern-oneblock/for-developers)
{% endcontent-ref %}


# Features

## GUI

Manage everything about your island from a single menu — members, visitors, bans, toggles and live island info.

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-81fdd3ccfa68da032091b00602ae01a9b8318381%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Phases

Progress your island through a long chain of **phases** (Plains, Forest, Desert, Cave, Nether, End, Ancient City and many more — 20 ship by default). Each phase defines its own blocks, item drops, enchantment drops and mob spawns. Advancing far enough unlocks the next phase automatically.

## Title & Info

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-81fdd3ccfa68da032091b00602ae01a9b8318381%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-81fdd3ccfa68da032091b00602ae01a9b8318381%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Roles & Members

Islands are shared. Each player on an island holds a role — **Owner**, **Moderator**, **Trusted** or **Visitor** — and members can be invited, promoted, demoted, kicked or banned. The number of members allowed is controlled per permission group via `custom-max-players`.

## Island bank

`/is bank` opens a shared balance per island. Any member can pay in with `/is bank deposit <amount>`; only the owner and moderators can take money out with `/is bank withdraw <amount>`.

The bank is the plugin's own balance rather than an economy account, so the shop and upgrades work **even with no economy plugin installed** — only moving money in and out of a player's wallet needs Vault.

## Island shop

`/is shop` opens a paginated sell menu driven by `worth.yml`. Left click sells one stack, right click sells every matching stack, and the proceeds go into the island bank.

Items carrying a display name, lore or enchantments are never sold, so a named or enchanted tool cannot be lost to a mistimed click.

## Island upgrades

`/is upgrades` are bought from the island bank by the owner or a moderator. Tiers, costs and values are all configured in `upgrades.yml` — delete a section to switch that upgrade off.

<table><thead><tr><th width="220">Upgrade</th><th>Effect</th></tr></thead><tbody><tr><td>Border</td><td>Extra island border size, capped by <code>world-border.max-size</code></td></tr><tr><td>Generator speed</td><td>Percentage off generator delay, capped at 90%</td></tr><tr><td>Member slots</td><td>Extra members on top of the permission-based limit</td></tr><tr><td>Mob limit</td><td>Living entities allowed inside the island border</td></tr><tr><td>Hopper limit</td><td>Hoppers allowed <strong>per chunk</strong> inside the island</td></tr></tbody></table>

## Per-role island settings

`/is flags` opens a matrix of what each role may do — eight settings in total:

* **Per role** — Build, Break, Containers, Flight.
* **Whole island** — PvP, Mob spawning, Visitor access, Redstone.

Islands store only what they have explicitly changed, so editing a default under `island-flags` in `config.yml` reaches every island that never touched that setting. The shipped defaults reproduce the classic behaviour exactly: members may act on the island, visitors may not, and PvP is off. PvP is read from the island the *victim* is standing on, so shooting in from outside the border does not bypass it.

## Island home & warps

* `/is sethome` — moves where `/is` lands. Owner and moderators only.
* `/is warp set <name> [public]` — name a place on your island.
* `/is warp go <name>` / `/is warp delete <name>`, with tab completion.
* `/is warp` — browse every public warp on the server, with the island's owner and level. Public warps still honour bans and visitor access.

Homes and warps must be inside the island border, so neither can drop members into the void or onto someone else's island. Nine warps per island.

## Leaderboards & island value

`/is top` opens a paginated ranking menu, switchable between **level**, **island value** and **blocks mined**.

**Island value** is a block-worth score built from `worth.yml`, maintained as blocks are placed and broken. It is incremental — adding a block to `worth.yml` does not retroactively count the ones already standing.

**Hologram leaderboards** can be configured in any number under `tops.holograms` in `config.yml`, using whichever hologram plugin you already have.

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-a0c8502e34719b7044a4071b2722d3917925a463%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Island chat

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-4a6975bcc4663ad9a1f9a6c741d3d0490c824fea%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Visiting & Open/Close

Islands can be opened to visitors with `/is open` (and closed again with `/is close`), and other players can drop in with `/is visit <player>`.

## Generators

Give players placeable **generators** that periodically produce a weighted pool of blocks (wood, redstone, prismarine, amethyst, nether, dripstone…). Fully configurable in `generators.yml` — see [generators.md](/products/modern-oneblock/generators).

## World Border

Each island is bounded by a resizable world border. Staff can expand a player's border with `/oneblockborder add <player> <size>` up to `world-border.max-size`, and players can buy extra size through the Border upgrade.

## MiniMessage support

Setting `text-format: MINIMESSAGE` in `config.yml` switches the whole plugin over to MiniMessage (`<green>tags</green>`). The default stays `LEGACY` so existing files keep their meaning; switching does not convert your files for you.

## Placeholders & Leaderboards

Exposes `%oneblock_*%` placeholders including per-island stats, island value, bank balance, rank and top-island leaderboards. See [placeholders.md](/products/modern-oneblock/placeholders).

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-f85d6e40f902c19cdb1f4da76a5d4b1dc7a53af8%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-62e20ede4af31488e12bcd38ef8c09e5974ba64a%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Integrations

Optional hooks are available for **Vault** (moving money between a player and the island bank), **PlaceholderAPI**, **LuckPerms** (member limits), **MythicMobs**, **ItemsAdder**, **Nexo** and **JetsMinions**.


# Installation

{% hint style="warning" %}
Modern OneBlock requires **Java 25** and a **Minecraft 26.1+** server. **Paper** and **Folia** are both supported.
{% endhint %}

* Drop `ModernOneBlock-26.1.0.jar` into the server `plugins` folder, then start the server once to generate the configuration and the island world.
* **VoidGen is no longer required.** The islands world is generated by a bundled void generator, so you can uninstall VoidGen.
* By default the plugin uses an embedded **H2** database and works with no extra setup.
* To use **MariaDB**, set `database.type` to `MariaDB` and fill in the `database.mariadb` credentials in `config.yml`.
* Data lives in `plugins/ModernOneBlock/`. If you are updating from 1.0.8, read [upgrading.md](/products/modern-oneblock/upgrading) — your old `plugins/OneBlock/` folder is moved for you.

## Optional dependencies

Install any of these to enable the matching feature:

* **Vault** — moving money between a player's own balance and the island bank. The bank, shop and upgrades work without it.
* **PlaceholderAPI** — `%oneblock_*%` placeholders.
* **LuckPerms** — per-group island member limits (`custom-max-players`).
* **HolographicDisplays**, **DecentHolograms**, **FancyHolograms** or **CMI** — leaderboard/info holograms.
* **MythicMobs**, **ItemsAdder**, **Nexo**, **JetsMinions** — content integrations.


# Upgrading from 1.0.8

Moving a live server from UltimateOneBlock 1.0.8 to Modern OneBlock 26.1.0

## What changed

<table><thead><tr><th width="200">​</th><th width="240">Before</th><th>Now</th></tr></thead><tbody><tr><td>Minecraft</td><td>1.21.x</td><td><strong>26.1+</strong></td></tr><tr><td>Java</td><td>21</td><td><strong>25</strong></td></tr><tr><td>Plugin name</td><td>OneBlock</td><td><strong>ModernOneBlock</strong></td></tr><tr><td>Data folder</td><td><code>plugins/OneBlock/</code></td><td><strong><code>plugins/ModernOneBlock/</code></strong></td></tr><tr><td>Jar</td><td><code>UltimateOneBlock-2.0.0.jar</code></td><td><code>ModernOneBlock-26.1.0.jar</code></td></tr><tr><td>Folia</td><td>not supported</td><td><strong>supported</strong></td></tr></tbody></table>

## Your data folder is moved for you

On first start the plugin looks for `plugins/OneBlock/` and moves it to `plugins/ModernOneBlock/`, carrying your configuration, your edited language files and the H2 island database with it. If the new folder already exists it is left alone. If the move fails, the plugin copies instead and tells you in the console — the old folder is never deleted.

{% hint style="warning" %}
The database schema is migrated automatically, and only forward. Take a backup of `oneblock-db-h2.mv.db` (or your MariaDB database) before the first start anyway: a failed migration aborts startup rather than leaving a half-built schema.
{% endhint %}

## VoidGen is no longer required

The islands world is generated by a bundled void generator. You can uninstall VoidGen.

## What deliberately did not change

Servers depend on these, so they were left alone:

* Permission nodes are still `oneblock.*`.
* The islands world is still `islands`.
* `/is`, `/ob` and `/oneblock` are unchanged.
* Generator items keep their `oneblock:` data keys — existing placed generators still work.

## Version numbering

Versions are now `<mc-major>.<mc-drop>.<release>`. The first two segments are the oldest Minecraft drop a build supports, so `26.1.0` is the first release for Minecraft 26.1. A build that required 26.2 would be `26.2.0`.

## New config files

Three additions appear on first start. Existing files are never overwritten.

* `worth.yml` — item sell prices and block worth for island value.
* `upgrades.yml` — island upgrade tiers and costs.
* New sections in `config.yml`: `island-flags`, `tops.holograms`, `text-format`.

## New permissions

All default to `true`:

`oneblock.bank`, `oneblock.shop`, `oneblock.upgrades`, `oneblock.flags`, `oneblock.sethome`, `oneblock.warp`, `oneblock.top`.

Every `oneblock.*` node is now declared in `plugin.yml`, so permission plugins can finally see them.

## Known limitations

* **Island value is incremental.** It counts blocks placed and broken while the plugin is running, so blocks already standing before a `worth.yml` entry existed are not counted.
* **The hopper limit is per chunk**, not per island.
* **On Folia the mob limit is skipped** when an island's bounding box spans regions.
* **On Folia, island holograms are not removed at shutdown.** The bundled provider's holograms are non-persistent and do not survive the restart either way.


# Configuration

## config.yml

{% code title="config.yml" fullWidth="false" %}

```yaml
# Tracks which configuration migrations have been applied. Do not edit by hand;
# lowering it will re-run migrations against your files.
config-version: 1

# H2 and MariaDB are the only supported
database:
  type: "H2"
  mariadb:
    host: "localhost"
    port: 3306
    database: "island"
    username: "root"
    password: ""

lang: en-US

# How the strings in lang/*.yml and the item names below are written.
#   LEGACY       - "&a" colour codes (the default, and what the shipped files use)
#   MINIMESSAGE  - "<green>tags</green>"
# Switching this does not convert your files; pick the one they are written in.
text-format: LEGACY

# From central block to central block
# 1000 blocks means maximum 500 blocks from center to edge
island-distance: 1000

# If true, on login the player will be teleported to the island if the spawn is not enabled
island-join-on-first-login: true

# Teleport the player to their island when they fall in the void
island-teleport-on-fall: true

# Determines how many members can be on an island based on owner permission
# If you want to have unlimited members, set it to -1
# Permission: oneblock.max.<role>
# LUCKPERMS NEEDED
custom-max-players:
  default: 8
  vip: 12
  mvp: 16
  admin: -1

# If level < level-formula then the island will level up
#
# islandCoreBroken (Island core blocks broken)
# level (Island current level)
# phaseOrdinal (1 = First Phase, 2 = Second Phase, 3 = Third Phase)
level-formula: "floor(3.55 * log(islandCoreBroken + 211) - 18)"

world-protection:
  # "Spawn worlds"
  blacklist:
    - "world"

spawn:
  enabled: true
  # Spawn location
  world: "world"
  x: 0
  y: 100
  z: 0
  yaw: 0
  pitch: 0

reset:
  cost: 1000
  phase: Dungeon

world-border:
  base-size: 127
  max-size: 127

tops:
  # How often the rankings are rebuilt, in ticks. The rebuild is skipped
  # entirely when nothing has changed since the last one.
  refresh-interval: 200
  # How many islands each ranking holds. /is top pages through this many, and
  # %oneblock_top_<n>% is only answered up to it.
  top-size: 10

  # Standing hologram leaderboards, using whichever hologram plugin is
  # installed. Remove or empty this section for none.
  #
  #   type:  level, value or blocks
  #   lines: how many places to show, capped by top-size above
  #
  # holograms:
  #   spawn-level:
  #     type: level
  #     lines: 10
  #     world: world
  #     x: 0.5
  #     y: 70.0
  #     z: 0.5

hologram:
  enabled: true
  # Unlock hologram when player reaches this number of blocks
  unlock-at: 50000
  update-interval: 5

gui:
  title: "&8Island"
  items:
    no-member:
      material: "GRAY_STAINED_GLASS_PANE"
      displayName: "&7Available Slot"
      lore: [ ]
      left-action:
        command: ""
      right-action:
        command: ""
    member:
      material: "PLAYER_HEAD"
      displayName: "&b&l%player%"
      lore:
        - ""
        - "&7Role: &d%prefix%"
        - ""
        - "&7Left-click to &cBan"
        - "&7Right-click to &cKick"
      left-action:
        command: "is ban %target%"
      right-action:
        command: "is kick %target%"
    no-visitor:
      material: "YELLOW_STAINED_GLASS_PANE"
      displayName: "&eNo Visitors"
      lore: [ ]
      left-action:
        command: ""
      right-action:
        command: ""
    visitor:
      material: "PLAYER_HEAD"
      displayName: "&a&l%player%"
      lore:
        - ""
        - "&7Left-click to &cBan"
      left-action:
        command: "is ban %target%"
      right-action:
        command: ""
    info:
      material: "EXPERIENCE_BOTTLE"
      displayName: "&b&lInfo"
      lore:
        - ""
        - "&eOwner: &a%owner%"
        - "&eMembers: &b%members%"
        - "&eBans: &b%bans%"
        - ""
        - "&eBlocks Broken: &b%core-broken%"
        - "&eTotal Blocks Broken: &b%total-core-broken%"
        - "&eLevel: &b%level%"
        - ""
        - "&ePhase: &b%phase%"
        - "&eNext Phase: &b%nextPhase%"
        - "&ePhase Progress: &r%progress% &e(&b%percent%%&e)"
        - ""
        - "&eIsland Size: &b%size%"
      left-action:
        command: ""
      right-action:
        command: ""
    toggle:
      # When the island is open, this will be the item
      open:
        material: "BARRIER"
        displayName: "&c&lClose"
        lore: [ ]
        left-action:
          command: "is close"
        right-action:
          command: "is close"
      close:
        material: "BARRIER"
        displayName: "&a&lOpen"
        lore: [ ]
        left-action:
          command: "is open"
        right-action:
          command: "is open"

# What each role on an island may do, before the island changes anything itself.
#
# Islands store only the settings they have explicitly changed through
# /is flags, so editing a default here reaches every island that has never
# touched that setting.
#
# BUILD, BREAK, CONTAINERS and FLY are per role. PVP, MOB_SPAWNING,
# VISITOR_ACCESS and REDSTONE are one switch for the whole island -- PvP cannot
# be on for moderators and off for visitors, because it takes two people -- and
# only their OWNER line is read.
island-flags:
  defaults:
    BUILD:
      OWNER: true
      MODERATOR: true
      TRUSTED: true
      VISITOR: false
    BREAK:
      OWNER: true
      MODERATOR: true
      TRUSTED: true
      VISITOR: false
    CONTAINERS:
      OWNER: true
      MODERATOR: true
      TRUSTED: true
      VISITOR: false
    FLY:
      OWNER: true
      MODERATOR: true
      TRUSTED: true
      VISITOR: false
    PVP:
      OWNER: false
    MOB_SPAWNING:
      OWNER: true
    VISITOR_ACCESS:
      OWNER: true
    REDSTONE:
      OWNER: true
```

{% endcode %}

### Text format

`text-format` decides how every string in `lang/*.yml` and every item name above is read: `LEGACY` for `&a` colour codes (the default, and what the shipped files use) or `MINIMESSAGE` for `<green>tags</green>`. Switching does not convert your files — pick the one they are written in.

### Hologram leaderboards

`tops.holograms` holds any number of standing leaderboards, drawn with whichever hologram plugin is installed. Each entry takes a `type` (`level`, `value` or `blocks`), a `lines` count (capped by `tops.top-size`) and a `world` / `x` / `y` / `z` position. Remove or empty the section for none.

### Island flags

`island-flags.defaults` is what each role may do before an island changes anything itself. Islands store only the settings they have explicitly changed through `/is flags`, so editing a default here reaches every island that never touched that setting.

`BUILD`, `BREAK`, `CONTAINERS` and `FLY` are per role. `PVP`, `MOB_SPAWNING`, `VISITOR_ACCESS` and `REDSTONE` are one switch for the whole island — PvP cannot be on for moderators and off for visitors, because it takes two people — and only their `OWNER` line is read.

## worth.yml

Sell prices for `/is shop` and block worth for island value. The two lists are separate on purpose: sell prices are economy balance and get tuned, while block worth is a ranking weight and wants to stay stable. Anything not listed is worth nothing — unsellable, and ignored by island value.

{% code title="worth.yml" fullWidth="false" %}

```yaml
# What things are worth.
#
# items:  money paid into the island bank when sold through /is shop.
# blocks: how much a placed block adds to the island's value, which is what
#         /is top ranks by. Broken blocks subtract the same amount.
#
# The two lists are separate on purpose: sell prices are economy balance and
# get tuned, while block worth is a ranking weight and wants to stay stable.
# Use the same numbers in both if you want them to agree.
#
# Anything not listed is worth nothing: unsellable, and ignored by island value.
# Unknown material names are reported in the console and skipped.

items:
  COBBLESTONE: 0.5
  STONE: 1.0
  COAL: 4.0
  IRON_INGOT: 12.0
  GOLD_INGOT: 20.0
  REDSTONE: 3.0
  LAPIS_LAZULI: 5.0
  DIAMOND: 100.0
  EMERALD: 80.0
  NETHERITE_INGOT: 1200.0
  ANCIENT_DEBRIS: 300.0
  QUARTZ: 8.0
  AMETHYST_SHARD: 15.0
  ECHO_SHARD: 250.0
  PRISMARINE_SHARD: 10.0
  NETHER_STAR: 2500.0

blocks:
  IRON_BLOCK: 100.0
  GOLD_BLOCK: 180.0
  DIAMOND_BLOCK: 900.0
  EMERALD_BLOCK: 720.0
  NETHERITE_BLOCK: 10000.0
  LAPIS_BLOCK: 45.0
  REDSTONE_BLOCK: 27.0
  COAL_BLOCK: 36.0
  BEACON: 2500.0
  AMETHYST_BLOCK: 60.0
```

{% endcode %}

## upgrades.yml

Island upgrades, bought from the island bank by the owner or a moderator. Values are **absolute, not incremental**: a tier says what the island has at that tier, not what it adds to the previous one. Delete an upgrade's whole section to switch it off.

{% code title="upgrades.yml" fullWidth="false" %}

```yaml
# Island upgrades.
#
# Every upgrade is bought from the island bank (/is bank), and only the island
# owner or a moderator may buy one.
#
# Values are ABSOLUTE, not increments: a tier says what the island has at that
# tier, not what it adds to the previous one. Buying tier 3 straight after
# tier 1 therefore lands on the right number.
#
# Delete an upgrade's whole section to switch it off. An upgrade with no tiers
# is not offered.

upgrades:

  # Blocks added to world-border.base-size in config.yml.
  # Note that world-border.max-size still caps the total.
  border:
    material: OAK_FENCE
    display-name: "&aIsland border"
    base: 0
    tiers:
      1:
        cost: 5000.0
        value: 20
      2:
        cost: 20000.0
        value: 40
      3:
        cost: 60000.0
        value: 60

  # Percentage taken off every generator's delay on this island.
  # Capped at 90 in code -- an instant generator is a dupe machine.
  generator-speed:
    material: SUGAR
    display-name: "&aGenerator speed"
    base: 0
    tiers:
      1:
        cost: 7500.0
        value: 15
      2:
        cost: 25000.0
        value: 30
      3:
        cost: 75000.0
        value: 50

  # Extra member slots, on top of whatever custom-max-players grants the owner.
  members:
    material: PLAYER_HEAD
    display-name: "&aMember slots"
    base: 0
    tiers:
      1:
        cost: 10000.0
        value: 2
      2:
        cost: 35000.0
        value: 4
      3:
        cost: 100000.0
        value: 8

  # Living non-player entities allowed inside the island border.
  # Remove this section for no limit.
  mob-cap:
    material: ZOMBIE_HEAD
    display-name: "&aMob limit"
    base: 60
    tiers:
      1:
        cost: 15000.0
        value: 100
      2:
        cost: 50000.0
        value: 150

  # Hoppers allowed PER CHUNK inside the island.
  # Per chunk rather than per island: counting island-wide means walking every
  # chunk of the island every time a hopper is placed.
  hopper-cap:
    material: HOPPER
    display-name: "&aHopper limit"
    base: 8
    tiers:
      1:
        cost: 12500.0
        value: 16
      2:
        cost: 40000.0
        value: 32
```

{% endcode %}


# Phase

How to configure a phase

## Example

{% hint style="info" %}
This is an example of a configuration.
{% endhint %}

{% hint style="warning" %}
Enchantments, materials and mobs use **modern (1.20.5+) names** — `PROTECTION`, not `PROTECTION_ENVIRONMENTAL`; `TURTLE_SCUTE`, not `SCUTE`; `SNOW_GOLEM`, not `SNOWMAN`. Every shipped phase file has been migrated. A value that cannot be resolved is now logged with the file and key that caused it instead of silently falling back.
{% endhint %}

{% code title="Plains.yml" fullWidth="false" %}

```yaml
################################################
# Plains Phase Configuration File
#
# How to use:
# - "Material:Percentage"

# - "Item:Amount:Percentage:"
# - "Item:Amount:Percentage:Enchantment:Level"
# - "Item:Amount:Percentage:Enchantment:Level:Enchantment:Level: ..."
#
# - "Mob:Percentage"
#
# - "Enchantment:Level:Percentage"
#
################################################

name: "Plains"
biome: "PLAINS"
next-phase: 6000

blocks:
  - "GRASS_BLOCK:20"
  - "DIRT:10"
  - "OAK_LOG:9"
  - "BIRCH_LOG:3"
  - "DARK_OAK_LOG:3"
  - "STONE:12"
  - "COBBLESTONE:5"
  - "OAK_LEAVES:6"
  - "SPRUCE_LEAVES:4"
  - "CLAY:5"
  - "GRAVEL:5"
  - "COAL_ORE:3"
  - "IRON_ORE:2"
  - "GOLD_ORE:2"
  - "DIRT_PATH:5"
  - "BONE_BLOCK:4"
  - "CHEST:2"

# A chest must be present in the block list
items:
  - "POPPY:1:6"
  - "DANDELION:1:4"
  - "EXPERIENCE_BOTTLE:2:8"
  - "LAPIS_LAZULI:4:4"
  - "LEAD:1:6"
  - "LEATHER:4:5"
  - "MUSIC_DISC_BLOCKS:1:1"
  - "EMERALD:2:2"
  - "ENCHANTED_BOOK:1:1"
  - "WHEAT_SEEDS:8:10"
  - "LADDER:4:4"
  - "FEATHER:4:3"
  - "SUGAR_CANE:6:9"
  - "OAK_FENCE:8:6"
  - "OAK_FENCE_GATE:1:6"
  - "COMPOSTER:1:5"
  - "BEETROOT_SEEDS:4:6"
  - "APPLE:1:6"
  - "WATER_BUCKET:1:5"
  - "BONE_BLOCK:4:1"
  - "COOKED_BEEF:2:2"

# A chest must be present in the block list
enchantments:
  - "PROTECTION:1:18"
  - "PROTECTION:2:16"
  - "FIRE_PROTECTION:1:17"
  - "FIRE_PROTECTION:2:16"
  - "FEATHER_FALLING:1:17"
  - "FEATHER_FALLING:2:16"

mobs:
  # Spawn mob randomly (-1 to disable)
  percentage: 50
  # Spawn mob every x block (-1 to disable)
  every-x-block: 75
  list:
    - "SPIDER:15"
    - "SHEEP:25"
    - "CHICKEN:13"
    - "COW:20"
    - "PIG:17"
    - "SKELETON:10"
```

{% endcode %}

### Formats

"<mark style="color:red;">Material:Percentage</mark>"

"<mark style="color:red;">Item:Amount:Percentage</mark>"\
"<mark style="color:red;">Item:Amount:Percentage:Enchantment:Level</mark>" "<mark style="color:red;">Item:Amount:Percentage:Enchantment:Level:Enchantment:Level: ...</mark>"

"<mark style="color:red;">Mob:Percentage</mark>"\
"<mark style="color:red;">Enchantment:Level:Percentage</mark>"


# Generators

How to configure a generator

## Example

{% hint style="info" %}
This is an example of a configuration.
{% endhint %}

{% code title="generators.yml" fullWidth="false" %}

```yaml
gui:
  title: "&6&lGenerators"
  date-format: "HH:mm:ss dd/MM/yyyy"
  generators:
    lore:
      - ""
      - "&7Blocks: &d%blocks%"
      - "&7Delay: &d%delay%"
      - "&7Probability: &d%probability%"
      - ""
      - "&7Coords: &d%x%&7, &d%y%&7, &d%z%"
      - "&7Placed At: &d%date%"

generators:
  wood:
    name: "Wooden Generator"
    delay: 1000
    success-probability: 50
    buffer: "COBBLESTONE"
    materials:
      - "OAK_LOG:10"
      - "SPRUCE_LOG:10"
      - "BIRCH_LOG:10"
      - "JUNGLE_LOG:10"
      - "ACACIA_LOG:10"
      - "DARK_OAK_LOG:10"
      - "OAK_PLANKS:10"
      - "SPRUCE_PLANKS:10"
      - "BIRCH_PLANKS:5"
      - "JUNGLE_PLANKS:5"
      - "ACACIA_PLANKS:5"
      - "DARK_OAK_PLANKS:5"
    item:
      material: "OAK_LOG"
      displayName: "&aWooden Generator"
      lore:
        - ""
        - "&7Delay: &b%delay%s"
        - "&7Success Rate: &b%success%%"

  redstone:
    name: "Redstone Generator"
    delay: 3000
    success-probability: 40
    buffer: "COBBLESTONE"
    materials:
      - "REDSTONE_BLOCK:20"
      - "REDSTONE_ORE:80"
    item:
      material: "REDSTONE_BLOCK"
      displayName: "&cRedstone Generator"
      lore:
        - ""
        - "&7Delay: &b%delay%s"
        - "&7Success Rate: &b%success%%"

  prismarine:
    name: "Prismarine Generator"
    delay: 1000
    success-probability: 60
    buffer: "COBBLESTONE"
    materials:
      - "PRISMARINE:30"
      - "PRISMARINE_BRICKS:30"
      - "DARK_PRISMARINE:30"
      - "SEA_LANTERN:10"
    item:
      material: "SEA_LANTERN"
      displayName: "&bPrismarine Generator"
      lore:
        - ""
        - "&7Delay: &b%delay%s"
        - "&7Success Rate: &b%success%%"

  amethyst:
    name: "Amethyst Generator"
    delay: 1000
    success-probability: 40
    buffer: "COBBLESTONE"
    materials:
      - "AMETHYST_BLOCK:25"
      - "BUDDING_AMETHYST:25"
      - "CALCITE:25"
      - "TUFF:25"
    item:
      material: "AMETHYST_BLOCK"
      displayName: "&dAmethyst Generator"
      lore:
        - ""
        - "&7Delay: &b%delay%s"
        - "&7Success Rate: &b%success%%"

  nether:
    name: "Nether Generator"
    delay: 1000
    success-probability: 60
    buffer: "COBBLESTONE"
    materials:
      - "GILDED_BLACKSTONE:20"
      - "BLACKSTONE:40"
      - "NETHER_GOLD_ORE:20"
      - "NETHER_QUARTZ_ORE:20"
    item:
      material: "GILDED_BLACKSTONE"
      displayName: "&4Nether Generator"
      lore:
        - ""
        - "&7Delay: &b%delay%s"
        - "&7Success Rate: &b%success%%"

  dripstone:
    name: "Dripstone Generator"
    delay: 1000
    success-probability: 70
    buffer: "COBBLESTONE"
    materials:
      - "DRIPSTONE_BLOCK:30"
      - "ROOTED_DIRT:20"
      - "MUD:10"
      - "MOSS_BLOCK:20"
      - "ANDESITE:20"
    item:
      material: "DRIPSTONE_BLOCK"
      displayName: "&6Dripstone Generator"
      lore:
        - ""
        - "&7Delay: &b%delay%s"
        - "&7Success Rate: &b%success%%"
```

{% endcode %}

### Fields

Each entry under `generators` defines one generator type (the key, e.g. `wood`, is the id used by `/generator <id>`):

* **name** — display name used in messages.
* **delay** — time between generation attempts, in milliseconds. The island's Generator speed upgrade takes a percentage off this.
* **success-probability** — chance (0–100) that an attempt produces a block.
* **buffer** — the block placed while the generator is on cooldown.
* **materials** — the weighted output pool, one entry per line as "<mark style="color:red;">Material:Weight</mark>". Weights are relative, so a higher number means the material is picked more often.
* **item** — the in-game item players receive for this generator (`material`, `displayName`, `lore`). `%delay%` and `%success%` are replaced in the lore.

## Images

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-f363fe406ea73029f306a6ff6175aa4ee9c1beaf%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-0b1c131b0976f13b69a64de436ac1a7b5572a711%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>


# Placeholders

Modern OneBlock registers a **PlaceholderAPI** expansion under the identifier `oneblock`. Placeholders resolve for the player they are parsed for. The `island_` prefix targets the player's **own** island, while the plain form targets the island the player is currently standing on.

{% hint style="info" %}
Placeholders return `Not Loaded` when the player or the relevant island isn't loaded yet.
{% endhint %}

## Player

<table><thead><tr><th width="300">Placeholder</th><th>Description</th></tr></thead><tbody><tr><td>%oneblock_player_fly%</td><td>Whether the player has island fly toggled on</td></tr><tr><td>%oneblock_player_chat%</td><td>Whether the player has island chat toggled on</td></tr></tbody></table>

## Island

Replace the `island_` prefix with nothing to read the island the player is currently on (e.g. `%oneblock_level%`), or keep it to read the player's own island (e.g. `%oneblock_island_level%`).

<table><thead><tr><th width="320">Placeholder</th><th>Description</th></tr></thead><tbody><tr><td>%oneblock_island_level%</td><td>Island level</td></tr><tr><td>%oneblock_island_id%</td><td>Island id</td></tr><tr><td>%oneblock_island_name%</td><td>Island name</td></tr><tr><td>%oneblock_island_owner%</td><td>Island owner's name</td></tr><tr><td>%oneblock_island_members%</td><td>Number of trusted members</td></tr><tr><td>%oneblock_island_visitors%</td><td>Number of current visitors</td></tr><tr><td>%oneblock_island_bans%</td><td>Number of banned players</td></tr><tr><td>%oneblock_island_open%</td><td>Whether the island is open to visitors</td></tr><tr><td>%oneblock_island_blocks%</td><td>Total island core blocks broken</td></tr><tr><td>%oneblock_island_current_blocks%</td><td>Core blocks broken in the current phase</td></tr><tr><td>%oneblock_island_percent_phase%</td><td>Progress toward the next phase</td></tr><tr><td>%oneblock_island_phase%</td><td>Current phase name</td></tr><tr><td>%oneblock_island_value%</td><td>Island value, from block worth in <code>worth.yml</code></td></tr><tr><td>%oneblock_island_balance%</td><td>Island bank balance</td></tr><tr><td>%oneblock_island_rank%</td><td>Island position in the level ranking</td></tr><tr><td>%oneblock_island_rank_value%</td><td>Island position in the value ranking</td></tr><tr><td>%oneblock_island_x%</td><td>Island core X coordinate</td></tr><tr><td>%oneblock_island_z%</td><td>Island core Z coordinate</td></tr></tbody></table>

## Leaderboards

<table><thead><tr><th width="320">Placeholder</th><th>Description</th></tr></thead><tbody><tr><td>%oneblock_top_&#x3C;position>%</td><td>Name of the island at the given leaderboard position</td></tr><tr><td>%oneblock_top_blocks_&#x3C;position>%</td><td>Blocks-broken total of the island at the given position</td></tr><tr><td>%oneblock_top_value_&#x3C;position>%</td><td>Island value at the given position in the value ranking</td></tr></tbody></table>

{% hint style="info" %}
`%oneblock_top_<position>%` still means the **level** ranking. Positions are only answered up to `tops.top-size` in `config.yml`.
{% endhint %}


# Command & Permissions

The plugin registers four commands: `island` (aliases `is`, `ob`, `oneblock`), `islandadmin` (alias `isadmin`), `oneblockborder` (alias `obborder`) and `generator`.

## Island — `/is`

Running `/is` with no arguments teleports you to your island.

<table><thead><tr><th width="180">Command</th><th width="130">Parameters</th><th width="220">Permission</th><th>Capabilities</th></tr></thead><tbody><tr><td>/is</td><td></td><td></td><td>Teleport to your island</td></tr><tr><td>/is help</td><td></td><td></td><td>Show the help menu</td></tr><tr><td>/is settings</td><td></td><td>oneblock.settings</td><td>Open the island GUI</td></tr><tr><td>/is info</td><td></td><td>oneblock.info</td><td>Show island info</td></tr><tr><td>/is fly</td><td></td><td>oneblock.fly</td><td>Toggle island fly</td></tr><tr><td>/is chat</td><td></td><td>oneblock.chat</td><td>Toggle island chat</td></tr><tr><td>/is open</td><td></td><td>oneblock.island.status</td><td>Open the island to visitors</td></tr><tr><td>/is close</td><td></td><td>oneblock.island.status</td><td>Close the island to visitors</td></tr><tr><td>/is name</td><td>&#x3C;name></td><td>oneblock.island.name</td><td>Rename the island</td></tr><tr><td>/is invite</td><td>&#x3C;player></td><td>oneblock.invite</td><td>Invite a player</td></tr><tr><td>/is join</td><td>&#x3C;player></td><td>oneblock.join</td><td>Join an island you were invited to</td></tr><tr><td>/is leave</td><td></td><td>oneblock.leave</td><td>Leave your island</td></tr><tr><td>/is leader</td><td>&#x3C;player></td><td>oneblock.leader</td><td>Transfer island ownership</td></tr><tr><td>/is promote</td><td>&#x3C;player></td><td>oneblock.promote</td><td>Promote a member</td></tr><tr><td>/is demote</td><td>&#x3C;player></td><td>oneblock.demote</td><td>Demote a member</td></tr><tr><td>/is kick</td><td>&#x3C;player></td><td>oneblock.kick</td><td>Kick a member</td></tr><tr><td>/is ban</td><td>&#x3C;player></td><td>oneblock.ban</td><td>Ban a player</td></tr><tr><td>/is unban</td><td>&#x3C;player></td><td>oneblock.unban</td><td>Unban a player</td></tr><tr><td>/is visit</td><td>&#x3C;player></td><td>oneblock.visit</td><td>Visit another player's island</td></tr><tr><td>/is phases</td><td></td><td>oneblock.phases</td><td>View the phases</td></tr><tr><td>/is generator</td><td></td><td>oneblock.generator</td><td>View the island's generators</td></tr><tr><td>/is border</td><td></td><td>oneblock.border</td><td>Cycle the island border color</td></tr><tr><td>/is bank</td><td>[deposit|withdraw] [amount]</td><td>oneblock.bank</td><td>Open the island bank, or move money in and out</td></tr><tr><td>/is shop</td><td></td><td>oneblock.shop</td><td>Sell items into the island bank</td></tr><tr><td>/is upgrades</td><td></td><td>oneblock.upgrades</td><td>View and buy island upgrades</td></tr><tr><td>/is flags</td><td></td><td>oneblock.flags</td><td>Change what each role may do</td></tr><tr><td>/is sethome</td><td></td><td>oneblock.sethome</td><td>Set where /is lands</td></tr><tr><td>/is warp</td><td></td><td>oneblock.warp</td><td>Browse the public warps</td></tr><tr><td>/is warp set</td><td>&#x3C;name> [public]</td><td>oneblock.warp</td><td>Create an island warp</td></tr><tr><td>/is warp go</td><td>&#x3C;name></td><td>oneblock.warp</td><td>Teleport to an island warp</td></tr><tr><td>/is warp delete</td><td>&#x3C;name></td><td>oneblock.warp</td><td>Delete an island warp</td></tr><tr><td>/is top</td><td></td><td>oneblock.top</td><td>Open the leaderboards (level, value, blocks)</td></tr><tr><td>/is reset</td><td></td><td>oneblock.reset</td><td>Reset your island</td></tr><tr><td>/is reload</td><td></td><td>oneblock.reload</td><td>Reload all configs</td></tr></tbody></table>

## Island Admin — `/isadmin`

<table><thead><tr><th width="200">Command</th><th width="130">Parameters</th><th width="240">Permission</th><th>Capabilities</th></tr></thead><tbody><tr><td>/isadmin leader</td><td>&#x3C;player></td><td>oneblock.admin.leader</td><td>Force-set an island's leader</td></tr><tr><td>/isadmin reset</td><td>&#x3C;player></td><td>oneblock.admin.reset</td><td>Force-reset a player's island</td></tr><tr><td>/isadmin promote</td><td>&#x3C;player></td><td>oneblock.admin.promote</td><td>Force-promote a player</td></tr><tr><td>/isadmin demote</td><td>&#x3C;player></td><td>oneblock.admin.demote</td><td>Force-demote a player</td></tr></tbody></table>

## Generator — `/generator`

<table><thead><tr><th width="200">Command</th><th width="130">Parameters</th><th width="270">Permission</th><th>Capabilities</th></tr></thead><tbody><tr><td>/generator</td><td>&#x3C;generator> [player]</td><td>oneblock.generator.give</td><td>Give a generator item (to yourself)</td></tr><tr><td>/generator</td><td>&#x3C;generator> &#x3C;player></td><td>oneblock.generator.give.other</td><td>Give a generator to another player</td></tr><tr><td>/generator miner</td><td>[player]</td><td>oneblock.generator.miner</td><td>Give the generator remover ("miner")</td></tr></tbody></table>

## World Border — `/oneblockborder`

<table><thead><tr><th width="230">Command</th><th width="150">Parameters</th><th width="230">Permission</th><th>Capabilities</th></tr></thead><tbody><tr><td>/oneblockborder add</td><td>&#x3C;player> &#x3C;size></td><td>oneblock.islandborder</td><td>Expand a player's island border (up to <code>world-border.max-size</code>)</td></tr></tbody></table>

{% hint style="info" %}
Every subcommand and argument completes client-side through Brigadier, and subcommands a player cannot use are not sent to their client at all.
{% endhint %}

## Other Permissions

<table><thead><tr><th width="280">Permission</th><th>Capabilities</th></tr></thead><tbody><tr><td>oneblock.bypass</td><td>Bypass island protection and access restrictions (op)</td></tr><tr><td>oneblock.admin.*</td><td>All administrative island permissions (op)</td></tr><tr><td>oneblock.max.&#x3C;role></td><td>Sets the island member limit tier (see <code>custom-max-players</code> in the config; requires LuckPerms)</td></tr></tbody></table>


# For Developers

{% hint style="warning" %}
**The package was renamed** `com.ohalee.ultimateoneblock` → `com.ohalee.modernoneblock`. Plugins built against 1.0.8 need recompiling.
{% endhint %}

## Public API

`OneBlockAPI` is the supported entry point, registered with Bukkit's `ServicesManager`. There is no need to cast the plugin class or reach into static fields any more.

{% code title="OneBlockAPI" %}

```java
import com.ohalee.modernoneblock.api.OneBlockAPI;

OneBlockAPI api = Bukkit.getServicesManager().load(OneBlockAPI.class);

api.island(uuid);                    // Optional<IslandView> — the player's island
api.islandAt(location);              // Optional<IslandView> — the island containing a location
api.islandUnder(player);             // Optional<IslandView> — the island a player stands on
api.islands();                       // every loaded island
api.phases();                        // every configured phase, in progression order
api.isIslandWorld(world);
api.canBuild(player, location);      // the plugin's own protection answer
```

{% endcode %}

Everything it returns is a read-only view: `IslandView`, `MemberView` and `PhaseView`, in `com.ohalee.modernoneblock.api.view`.

`IslandManager`, `GeneratorManager`, `PlayerManager` and `ProtectionService` are registered with the `ServicesManager` too, and are loaded the same way.

## Events

Modern OneBlock fires custom Bukkit events under `com.ohalee.modernoneblock.api.event`. Register a normal Bukkit `Listener` to handle them.

```java
com.ohalee.modernoneblock.api.event.GeneratorDestroyEvent
com.ohalee.modernoneblock.api.event.GeneratorPlaceEvent
com.ohalee.modernoneblock.api.event.IslandDeleteEvent
com.ohalee.modernoneblock.api.event.NewPhaseEvent
com.ohalee.modernoneblock.api.event.PlayerFlyEvent
com.ohalee.modernoneblock.api.event.PlayerIslandEnterEvent
com.ohalee.modernoneblock.api.event.PlayerIslandLeaveEvent
com.ohalee.modernoneblock.api.event.RoleChangeEvent
```

New in 26.1.0:

```java
com.ohalee.modernoneblock.api.event.IslandCreateEvent
com.ohalee.modernoneblock.api.event.IslandLevelUpEvent
com.ohalee.modernoneblock.api.event.MemberJoinEvent
com.ohalee.modernoneblock.api.event.MemberLeaveEvent   // with a reason: LEFT, KICKED, BANNED
com.ohalee.modernoneblock.api.event.CoreBreakEvent     // cancellable
com.ohalee.modernoneblock.api.event.MemberInviteEvent  // cancellable
com.ohalee.modernoneblock.api.event.IslandVisitEvent   // cancellable
```

Only events that fire *before* the action are cancellable, and every one of those is honoured.

## Breaking API changes

* **Package renamed** — `com.ohalee.ultimateoneblock` → `com.ohalee.modernoneblock`.
* **The static manager accessors are gone.** `UltimateOneBlock.islandManager()`, `.playerManager()`, `.database()` and friends no longer exist; use `OneBlockAPI` or the `ServicesManager` entries above.
* **`IEconomy`** replaces `withdrawPlayer(Player, int)` with `isPresent`, `balance`, `has`, `withdraw`, `deposit` and `format`, all on `double`.
* **`IHologram.init`** now takes `(String id, Location location, int interval)` rather than an `Island`. `init(Island, int)` remains as a default method.
* **`StaticTask`** replaces `runTask(runnable, async)` with schedulers that say *where* work runs: `atLocation`, `forEntity`, `global` and `async` (plus their `…Later` and `…Timer` variants). On a regionised server such as Folia there is no single main thread, so this distinction is not optional.

## Provider interfaces

The `api` package also defines pluggable provider interfaces you can implement to integrate with your own systems:

* `com.ohalee.modernoneblock.api.economy.IEconomy` — economy backend. Note that `isPresent()` is false when no economy plugin is installed: callers that *charge* may proceed anyway, but callers that would *pay out* must refuse rather than create money from nothing.
* `com.ohalee.modernoneblock.api.hologram.IHologram` — hologram backend (implementations exist for HolographicDisplays, DecentHolograms, FancyHolograms and CMI).


# Modern Tournament

Modern Tournament runs fully automated bracket tournaments — solo or team — with an optional live online bracket.

{% hint style="info" %}
UltimateTournament is now **Modern Tournament**. The base command is now `/mt` (the `/tournament` command is unchanged). Permissions (`ut.*`) and placeholders (`%ut_*%`) are unchanged.
{% endhint %}

{% content-ref url="/pages/U1SS76mNCbPwOg8ZqbmR" %}
[Features](/products/modern-tournament/features)
{% endcontent-ref %}

{% content-ref url="/pages/x8AjVov6SJTXjAFIvpF1" %}
[Installation](/products/modern-tournament/installation)
{% endcontent-ref %}

{% content-ref url="/pages/z9pQ3dZsToc0CumIxlyQ" %}
[Configuration](/products/modern-tournament/configuration)
{% endcontent-ref %}

{% content-ref url="/pages/kSNUJdGpXiaMPQmhBDRr" %}
[Tournament](/products/modern-tournament/tournament)
{% endcontent-ref %}

{% content-ref url="/pages/zvOo04EdUqHqfrmsfgod" %}
[The website](/products/modern-tournament/website)
{% endcontent-ref %}

{% content-ref url="/pages/5CsUuSHQQaVIopYPf45w" %}
[Placeholders](/products/modern-tournament/placeholders)
{% endcontent-ref %}

{% content-ref url="/pages/aEVu50pX48UjahYbIyBm" %}
[Command & Permissions](/products/modern-tournament/command-and-permissions)
{% endcontent-ref %}

{% content-ref url="/pages/jnKWWYis9g6kTZzmSsyW" %}
[Roadmap (v3)](/products/modern-tournament/roadmap)
{% endcontent-ref %}


# Features

## Fully automated bracket

The plugin builds the bracket, runs each round, teleports players into the arena, runs the countdown, decides the winner and advances the bracket automatically — round after round until a champion is crowned. An optional **3rd-place match** between the two semifinal losers can be toggled on.

## Four formats

Set `settings.format.type` and the whole tournament is drawn to match:

* **Single elimination** — lose once and you are out.
* **Double elimination** — a beaten team drops into a losers bracket and can fight its way back to the grand final.
* **Round robin** — everyone plays everyone, and the table decides it.
* **Groups → playoffs** — a group stage, then a knockout between whoever came top.

## Best-of-N matches

A match can be a single duel, a best of three or a best of five, with a longer final than the rest of the tournament if you want one. The same two teams play until one of them has a majority, and the online bracket shows the score as it stands rather than jumping at the end.

## Parallel matches

Raise `max_parallel_matches` and every match that is ready is fought at once, each in its own arena, instead of one at a time with the rest of your arenas standing idle. A 32-team tournament becomes five rounds rather than thirty-one duels in a queue.

## Offline players

In case a player does not show up for the tournament, the challenger wins by forfeit. If both are offline a random player is drawn and moves on to the next round.

## Match timeout

If a duel runs past a configurable time limit, the player with more health is declared the winner, so a stalling match can never freeze the bracket.

## 1vs1, 2vs2, 3vs3: unlimited modes

Set the max player per team and the max teams to create a complete new tournament level! In team modes players form teams with `/mt invite` before the tournament starts.

## Online Bracket

Set an `api_key` in the config and the tournament is mirrored to a live web bracket — teams, matches and results update in real time as the plugin plays out each round, and the bracket URL can be shown to players in chat.

The website is more than a view of it. Named API tokens replace the single account key, a tournament that goes wrong can be corrected from the dashboard, every tournament has a transparent [stream overlay](/products/modern-tournament/website#stream-overlay) for OBS, and events can be posted to your own endpoint over signed [webhooks](/products/modern-tournament/website#webhooks). See [the website](/products/modern-tournament/website).

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-f1e5224d7a69b86bb8d938a61469437a0320e15a%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Leaderboard

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-47a7d1b3b2da482b66861c4f06e0b9e2f421c0bc%2Fplac1.png?alt=media" alt=""><figcaption></figcaption></figure>

## Arena & Titles

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-17332a001fcae4b10669c6db8d427afd354e8653%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-8644e655710dfe1050e8709e0ba05fafd62cb10f%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-b10459ced689a951eae2d25a5261749e37b87720%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>


# Installation

* Install Modern Tournament in the server `plugins/` folder and restart.
* (Optional) Install **PlaceholderAPI** to expose the `%ut_*%` placeholders — the hook is automatic when PlaceholderAPI is present.
* (Optional) Set an `api_key` in `settings.yml` to enable the live [online bracket](/products/modern-tournament/features#online-bracket). Create a token on the dashboard rather than using the account key — it can be revoked on its own; see [API tokens](/products/modern-tournament/website#api-tokens).
* Set up your first arena and tournament — see [tournament.md](/products/modern-tournament/tournament).


# Placeholders

{% hint style="info" %}
The "pos" value can be unlimited
{% endhint %}

```
Arena Stats
%ut_<arena>_teams% - number of teams
%ut_<arena>_players% - number of players
%ut_<arena>_team% - player's team

Arena Standings
%ut_<arena>_top_user_<pos>%
%ut_<arena>_top_value_<pos>%

User arena stats
%ut_<arena>_top_pos%
%ut_<arena>_top_value%
%ut_<arena>_round_team1%
%ut_<arena>_round_team2%
%ut_<arena>_nametag_team1%
%ut_<arena>_nametag_team2%
```


# Configuration

Message strings use the [MiniMessage](https://docs.advntr.dev/minimessage/format.html) format (`<gold>`, `<red>`, …). Titles and legacy nametags still accept `§` colour codes.

## Configuration File

{% code title="settings.yml" %}

```yaml
# Logic behind the matches in case one or both players are offline
# at the time of the duel: if only one of the two players is offline,
# the other wins directly if both are offline, a lot draws a winner
settings:
  general:
    # If activated, teleports the player to the "exit" position
    teleport_after_duel: true
    # If activated, it saves the player's inventory before the duel and restores it after the duel
    save_and_restore_inventories: true
    # Require empty inventory before duel
    require_empty_inventory: false
    # If the player quit loses the duel, otherwise the duel ends when he dies (recommended default)
    handle_quit: true
    # The integration with placeholder api is automatic, therefore, it is advisable to leave the default value
    hook_in_papi: true
    starting_gamemode: SURVIVAL
    disable_hunger: true
    use_death_event: true
    no_team_name: "BYE"
    # Set this to enable the live online bracket (leave empty to disable)
    api_key: ""
    # Maximum minutes a live match can run before the higher-health player wins (0 = no limit)
    match_timeout_minutes: 10
    # Whether to run a 3rd-place match between the two semifinal losers
    third_place_match: true
    # How many matches may be fought at the same time, across different arenas
    max_parallel_matches: 1
    # This server's name on the website (leave empty to have one generated)
    server_id: ""

  # How the tournament is drawn
  format:
    # SINGLE_ELIMINATION - lose once and you are out (the default)
    # DOUBLE_ELIMINATION - you are out after two defeats; beaten teams drop into a
    #                      losers bracket and can fight their way back to the final
    # ROUND_ROBIN        - everyone plays everyone, and the table decides it
    # GROUPS_PLAYOFF     - a group stage, then a knockout between whoever came top
    type: SINGLE_ELIMINATION
    # DOUBLE_ELIMINATION only: play a decider if the unbeaten finalist loses the grand final
    grand_final_reset: true
    # GROUPS_PLAYOFF only. groups x qualify_per_group must be a power of two
    groups: 2
    qualify_per_group: 2
    # Games per match. Must be odd
    best_of: 1
    # Games in the last round, if you want a longer final than the rest
    best_of_final: 1

  nametag:
    team1: "§7[§cR§7] §c"
    team2: "§7[§9B§7] §9"

  messages:
    # %tournament% Name of the tournament
    # %rounds% Number of rounds
    # %max_teams% Maximum number of teams
    # %max_players_per_team% Maximum number of players per team
    # %uuid% (WEB Only) UUID of the tournament
    # %url% (WEB Only) URL of the tournament
    # Examples:
    # "§7(%max_teams% teams, %max_players_per_team% players per team)"
    # "§7Bracket: §a%url%"
    tournament_starting: "<gold>The tournament is starting! <gray>(%tournament%) <gray>(%rounds% rounds)"
    # %player% Name of the player involved (winner)
    opponent_offline: "<red>The opponent is offline! %player% won by forfeit."
    # %player% Name of the player involved (winner)
    both_opponents_offline: "<red>Both players are offline. Random drawing of the winner..."
    # %player% Name of the player involved (winner)
    match_timeout: "<gold>Match timed out! <green>%player% <gray>wins by health advantage."
    # %player% Name of the player involved (winner)
    match_victory: "<red>%loser% <gray>lost against <green>%winner% <gray>(<red>%health%❤) <gray>in the duel."

  titles:
    # %round% Group number
    round_presentation:
      title: "§6§l%round%° Round"
      subtitle: ""
      fadeIn: 500
      stay: 2000
      fadeOut: 500
    # %player1% Name of the first player involved
    # %player2% Name of the second player involved
    match_presentation:
      title: "§6§lDuel"
      subtitle: "§c%player1% §7vs §9%player2%"
      fadeIn: 500
      stay: 2000
      fadeOut: 500
    match_starting:
      title: "§6§lStarting in"
      subtitle: "%seconds%"
      fadeIn: 200
      stay: 800
      fadeOut: 200
    # %player% Name of the player involved (winner)
    match_victory:
      title: "§b§l%player% §7won"
      subtitle: ""
      fadeIn: 500
      stay: 2000
      fadeOut: 500
    # %player% Name of the player involved (winner)
    third_place:
      title: "§6§l3° Place"
      subtitle: ""
      fadeIn: 500
      stay: 2000
      fadeOut: 500
    # %player% Name of the player involved (winner)
    tournament_victory:
      title: "§d§l%player%"
      subtitle: "§fwon the tournament"
      fadeIn: 500
      stay: 2000
      fadeOut: 500

sounds:
  arena_join: "ENTITY_EXPERIENCE_ORB_PICKUP"
  arena_leave: "ENTITY_EXPERIENCE_ORB_PICKUP"
  match_starting: "ENTITY_EXPERIENCE_ORB_PICKUP"
  match_victory: "ENTITY_PLAYER_LEVELUP"
  tournament_victory: "ENTITY_ENDER_DRAGON_GROWL"

events:
  # When a player enters the arena for his duel
  # %player% Name of the player involved
  arena_join:
    commands: [ ]
  # When a player leaves the arena for his duel
  # %player% Name of the player involved
  arena_leave:
    commands: [ ]

commands:
  invite:
    not_supported: "<red>This command is not supported in this tournament</red>"
    player_not_found: "<red>Player not found</red>"
    invite_self: "<red>You can't invite yourself</red>"
    no_team: "<red>You are not in a team</red>"
    not_leader: "<red>You are not the leader of the team</red>"
    team_full: "<red>The team is already full</red>"
    already_in_team: "<red>Player is already in the team</red>"
    invite_error: "<red>An error occurred while inviting the player</red>"
    already_sent: "<red>Player %player% has already been invited</red>"
    invite_sent: "<green>Player %player% has been invited to the team</green>"
    invite_received: "<green>You have been invited to the team of %player%</green>"

  join:
    not_invited: "<red>No one has invited you in this team</red>"
    success: "<green>You signed up for the tournament</green>"
    success_team: "<green>You joined the team %team%</green>"
    leader: "<green>Player %player% joined your team</green>"
    no_team: "<red>Team not found</red>"
    full: "<red>The team is already full</red>"
    tournament_full: "<red>The tournament is already full</red>"
    already_participant: "<red>You are already registered for the tournament</red>"
    status_error: "<red>The tournament doesn't accept new registrations</red>"

  leave:
    not_participant: "<red>You are not registered for the tournament</red>"
    unsubscribe: "<green>You have unsubscribed from the tournament</green>"
```

{% endcode %}

## Formats

`settings.format.type` decides how the tournament is drawn. The setting is sent to the website when the tournament starts, so both ends draw the same one — change it before you start, not after.

<table><thead><tr><th width="220">Format</th><th>How it works</th></tr></thead><tbody><tr><td><code>SINGLE_ELIMINATION</code></td><td>Lose once and you are out. The default, and what every previous version played.</td></tr><tr><td><code>DOUBLE_ELIMINATION</code></td><td>You are out after two defeats. A beaten team drops into a losers bracket and can fight its way back to the grand final.</td></tr><tr><td><code>ROUND_ROBIN</code></td><td>Everyone plays everyone once, and the table decides it. There is no final.</td></tr><tr><td><code>GROUPS_PLAYOFF</code></td><td>A group stage, then a knockout bracket between whoever came top of each group.</td></tr></tbody></table>

**`grand_final_reset`** (double elimination only). The team that reaches the grand final through the winners bracket has not lost yet. If it loses there, both teams have one defeat each, and with this on a decider is played to settle it. Turn it off to end the tournament on the first grand final instead.

**`groups` and `qualify_per_group`** (groups → playoffs only). Their product must be a power of two, because the qualifiers go into a knockout bracket: 2 groups of 2 qualifiers, or 4 groups of 2, or 2 groups of 4. Teams are drawn into groups in a snake — the top seed to the first group, the second to the second, and back again — so the strongest are spread out rather than landing together. Qualifiers are placed so that the winner of one group never meets the runner-up of its own group in the first playoff round.

A group table is ordered by matches won, then game difference, then games won, and finally by seeding, so the order is total. Head-to-head is deliberately not used: it is ambiguous in a three-way tie, and a rule nobody can predict is worse than a blunt one everybody can.

## Best-of-N

`best_of` is the number of games a match takes, and `best_of_final` the number the last round takes if you want a longer final than the rest. Both must be **odd** — an even best-of can be drawn, and a drawn match has nowhere to send its two teams.

The same two teams play until one of them has won a majority, and it is the majority, not the last game, that advances. Each game is reported to the website as it finishes, so the online bracket shows the score as it stands rather than jumping at the end.

A game nobody turns up for settles the whole match rather than just that game. A best-of-five against a team that is offline would otherwise be three forfeits in a row, and a quarter of an hour of countdowns, to reach an answer already known at the first.

## Parallel matches

`max_parallel_matches` is how many matches may be fought at the same time. `1` is the old behaviour: one duel at a time, with every other arena standing idle while a 32-team tournament works through thirty-one of them in single file.

Raise it and every match that is ready to be played is dispatched at once, up to this many, each into its own arena. The same tournament becomes five rounds. You need at least that many arenas configured for it to make a difference — a match with no free arena waits for one, so the setting is a cap on concurrency rather than a promise of it. The tournament's own arena is always used first, so a server with one arena behaves exactly as it always did whatever this is set to.

## Bracket sync

The website owns the bracket. It draws one when a tournament starts and stores it; the plugin reads it and plays it. Every match has a fixed place — its round and its position in that round — that both ends agree on because only one of them decides it.

That was not always so. The plugin and the website each used to work out a bracket of their own, and the plugin then told the website the result of each match by its position in *its* copy. The two could come apart — a result that never arrived, a player added after the draw — and nothing noticed. Both implementations are gone; there is one.

What it means in practice:

* A result the bracket cannot justify is refused instead of stored. Reporting a winner who was not in the match, or a second, different winner for a match that already has one, fails loudly rather than quietly corrupting the bracket.
* Sending the same result twice is safe, so a retry after a timeout cannot duplicate or contradict anything.
* Byes are decided by the website when it draws the bracket, so both ends agree about who got a walkover.
* Adding or removing a player, or reshuffling the seeding, is refused once a tournament has started — the bracket is drawn from the entrant list at that moment, and changing it afterwards would describe a tournament nobody is playing.
* Results survive an outage or a restart. Each one is written to disk before it is sent and retried until the website confirms it, rather than being logged and dropped.
* A tournament is executed by one server, which claims it on start. If that server crashes, the match it was running is reopened when it comes back — and a server that has since been superseded is refused if it tries to report an old result.

`server_id` is the name the website records as the owner, so an operator who cannot start a tournament can see which machine is holding it. Leave it empty and one is generated and stored in the plugin folder; set it to something recognisable if you run more than one server.

If a tournament is held by a server that is gone for good, take it over deliberately rather than starting a second server on the same bracket.

{% hint style="warning" %}
This is v3 of the sync protocol, and it is the only one. A `2.x` plugin cannot talk to the current website, and a current plugin cannot talk to an older one — they need upgrading together. The plugin reports the protocol version it speaks when it connects, and says so plainly in the log if the two do not match.
{% endhint %}


# Tournament

How to configure a Tournament

## Tournament setup in Minecraft

1. Create an Arena with: /mt arena create \<name>
2. Setup the region (not where the players fight but the whole place):\
   /mt arena setcorner \<name> 1\
   /mt arena setcorner \<name> 2
3. Setup the enter places:\
   /mt arena setenter \<name> 1\
   /mt arena setenter \<name> 2
4. Setup the exit location: /mt arena setexit \<name>
5. (Optional) Save your inventory as kit: /mt arena savekit \<name>
6. Create the Tournament, binding it to the arena, with:\
   /mt create \<tournament> \<arena> \[max\_players\_per\_team] \[max\_teams]\
   (defaults: 1 player per team, 128 max teams)
7. You can still adjust the limits afterwards:\
   /mt setmaxplayer \<tournament> \<number>\
   /mt setmaxteams \<tournament> \<number>
8. Sign up for the tournament via /mt join \<tournament> \[team].\
   In team modes, the team leader forms the team with /mt invite \<tournament> \<player>.
9. At least two teams must be present to start the tournament. Run /mt start \<tournament>


# The website

API tokens, correcting a tournament that has gone wrong, the stream overlay, player records and webhooks.

Everything on this page lives on [tournament.ohalee.com](https://tournament.ohalee.com) and needs an active subscription. The plugin side is in [configuration.md](/products/modern-tournament/configuration).

## API tokens

Your account has one permanent key, shown on the dashboard. It works, and it always will — but it carries every permission there is, and the only way to change it is to refresh it, which breaks every server using it at once.

A **token** is the alternative. Create one per server, give it a name you will recognise, and it can be revoked on its own without touching anything else.

<table><thead><tr><th width="200">Scope</th><th>Lets a token</th></tr></thead><tbody><tr><td><code>tournament:read</code></td><td>Read tournaments, participants and brackets</td></tr><tr><td><code>tournament:write</code></td><td>Create tournaments, add and remove players, draw the bracket</td></tr><tr><td><code>match:write</code></td><td>Report match results and match state</td></tr><tr><td><code>admin</code></td><td>Override a result, void a match, release a tournament from its server</td></tr></tbody></table>

A new token defaults to the first three, which is exactly what a Minecraft server does. It deliberately does **not** get `admin`: a key sitting in a config file on a game server is the last one that should be able to rewrite a result.

Paste the token into `api_key` in `settings.yml` exactly as you would the old key — the plugin does not care which kind it is given.

{% hint style="warning" %}
The token is shown **once**, when you create it. Only a hash of it is stored, so it cannot be recovered or shown again. If you lose it, create another and revoke the old one — which is the whole reason for having more than one.
{% endhint %}

A token that lacks a scope is refused with `403` and told which scope it needed, so a misconfigured key is diagnosable from the response rather than from guesswork.

## When a tournament goes wrong

Open a tournament from your dashboard and you get a panel the public bracket does not show.

**Which server holds it.** A tournament is executed by one Minecraft server, which claims it on start. The panel names that server and when it claimed it, so "the tournament will not start" stops being a guessing game.

**What a crash left behind.** A match that a server was fighting when it died is listed separately. Usually you need do nothing: when that server comes back and attaches again, the match is reopened and played. The listing is for the case where it is not coming back.

**Setting a winner.** Pick a match, pick the winner, give a reason. The reason is required and is recorded.

**Voiding a match.** Discards its result and reopens it.

{% hint style="info" %}
Changing a match **replays the bracket** from the draw. Everything that followed from that match is recomputed, because those later matches only happened as a consequence of it — so a result recorded between two teams who no longer meet cannot be kept, and you are told how many were discarded.

Flipping a semi-final, for instance, drops both the final *and* the third-place match: the third-place match is between the two beaten semi-finalists, and you have just changed who those are.
{% endhint %}

Every correction is written to an audit log with who made it, when, and why, shown at the bottom of the same panel. A bracket that changed with nobody able to say why is worse than one that is simply wrong.

## Stream overlay

Every tournament has an overlay at:

```
https://tournament.ohalee.com/<uuid>/overlay
```

Add it as a **browser source** in OBS. It paints no background, so whatever is behind it in your scene shows through, and it shows only what is being fought right now and who has won — a bracket is not something anybody can read at overlay size.

Several matches appear at once if you have raised `max_parallel_matches`, and a best-of shows its running score. It holds the same live connection as the public bracket page, so an overlay left open for a whole tournament reconnects and catches up on its own.

Append `?bg=1` to give it a background when you want to check it in an ordinary browser, where transparent renders as white and looks broken.

## Player records

The dashboard shows a table of everybody who has played in your tournaments: how many they entered, how many they won, their match record and their win rate.

These are counted from the stored matches every time the page is opened, not kept as running totals. That means correcting a result — or deleting a tournament — is reflected immediately, with no way for the numbers to drift from the matches they describe.

A player is followed by their Minecraft UUID, so somebody who changes their name keeps their record and shows up under the name they use now.

## Webhooks

Post tournament events to your own endpoint — a Discord relay, a scoreboard, your own site.

Add an `https` URL on the dashboard and choose which events you want, or choose none to receive everything, including events added in future versions.

<table><thead><tr><th width="240">Event</th><th>Sent when</th></tr></thead><tbody><tr><td><code>tournament.seeded</code></td><td>The bracket is drawn and the tournament starts</td></tr><tr><td><code>tournament.finished</code></td><td>The deciding match is won</td></tr><tr><td><code>match.completed</code></td><td>A match is decided</td></tr><tr><td><code>match.overridden</code></td><td>Somebody corrected the bracket by hand</td></tr></tbody></table>

The body is JSON:

```json
{
  "event": "match.completed",
  "tournament": "8f14e45f-ceea-467a-9f6a-1f0f0e39c1a2",
  "sent_at": "2026-08-08T20:31:04.882Z",
  "data": {
    "bracket": "WINNERS",
    "round": 2,
    "order_number": 0,
    "winner_team_number": 3,
    "cause": "KILL",
    "seq": 41
  }
}
```

### Verifying a delivery

Every request carries a signature, so your endpoint can tell a real delivery from anybody who found the URL:

```
X-UT-Event: match.completed
X-UT-Signature: t=1786000264,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
```

`v1` is `HMAC-SHA256(secret, "<t>.<raw body>")` in hex, where `t` is the Unix timestamp in the same header and the body is the **raw** bytes, before any JSON parsing.

To verify: check `t` is within a few minutes of now, recompute the HMAC over `t` and the raw body, and compare it to `v1` — with a constant-time comparison, not `==`. The timestamp is part of what is signed, so a captured delivery cannot be re-stamped with a fresh one and still verify.

The secret is shown once when you add the webhook, the same as an API token.

### Delivery and retries

A delivery is written to the database in the same transaction as the change it describes, so you cannot be told about something that did not happen, and cannot miss something that did.

If your endpoint is unreachable or answers with an error, the delivery is retried: thirty seconds, then a minute, doubling to a maximum of six hours, over twelve attempts — a bit over a day in total. An endpoint down for an hour is not hammered for the hour, and one down overnight still gets everything when it comes back.

A `4xx` other than `408` or `429` is treated as final. That is your endpoint saying it will never accept this delivery, and retrying cannot change the answer.

Anything given up on is shown on the dashboard next to the webhook, with the last error, so a misconfigured endpoint is visible rather than silent.

{% hint style="success" %}
Answer `2xx` as soon as you have the delivery, and do your work afterwards. A slow endpoint is retried after ten seconds, which for a handler that is merely slow means doing the work twice.
{% endhint %}


# Command & Permissions

The base command is `/tournament`, aliased `/mt`.

## Arena

<table><thead><tr><th width="210">Command</th><th width="170">Parameters</th><th width="185">Permission</th><th>Capabilities</th></tr></thead><tbody><tr><td>/mt arena create</td><td>&#x3C;arena></td><td>ut.arena.create</td><td>Create an arena</td></tr><tr><td>/mt arena delete</td><td>&#x3C;arena></td><td>ut.arena.delete</td><td>Delete an arena</td></tr><tr><td>/mt arena list</td><td></td><td>ut.arena.list</td><td>Show all arenas</td></tr><tr><td>/mt arena savekit</td><td>&#x3C;arena></td><td>ut.arena.savekit</td><td>Save your inventory as the arena fight kit</td></tr><tr><td>/mt arena setexit</td><td>&#x3C;arena></td><td>ut.arena.setexit</td><td>Exit loc. after the fight</td></tr><tr><td>/mt arena setenter</td><td>&#x3C;arena> &#x3C;1-2></td><td>ut.arena.setenter</td><td>Enter loc. before the fight</td></tr><tr><td>/mt arena setcorner</td><td>&#x3C;arena> &#x3C;1-2></td><td>ut.arena.setcorner</td><td>Arena corners</td></tr></tbody></table>

## Tournament

<table><thead><tr><th width="210">Command</th><th width="270">Parameters</th><th width="175">Permission</th><th>Capabilities</th></tr></thead><tbody><tr><td>/mt list</td><td></td><td>ut.list</td><td>Show all tournaments</td></tr><tr><td>/mt create</td><td>&#x3C;tournament> &#x3C;arena> [max_players_per_team] [max_teams]</td><td>ut.create</td><td>Create a tournament bound to an arena</td></tr><tr><td>/mt delete</td><td>&#x3C;tournament></td><td>ut.delete</td><td>Delete a tournament</td></tr><tr><td>/mt removeall</td><td>&#x3C;tournament></td><td>ut.removeall</td><td>Remove all registered players</td></tr><tr><td>/mt reset</td><td>&#x3C;tournament></td><td>ut.reset</td><td>Reset a finished tournament back to waiting</td></tr><tr><td>/mt shuffle</td><td>&#x3C;tournament></td><td>ut.shuffle</td><td>Randomize the bracket seed</td></tr><tr><td>/mt start</td><td>&#x3C;tournament></td><td>ut.start</td><td>Start the tournament</td></tr><tr><td>/mt join</td><td>&#x3C;tournament> [team]</td><td>ut.join</td><td>Join the tournament</td></tr><tr><td>/mt leave</td><td>&#x3C;tournament></td><td>ut.leave</td><td>Leave the tournament</td></tr><tr><td>/mt invite</td><td>&#x3C;tournament> &#x3C;player></td><td>ut.invite</td><td>Invite a player to your team (team modes)</td></tr><tr><td>/mt setmaxteams</td><td>&#x3C;tournament> &#x3C;n></td><td>ut.setmaxteams</td><td>Set max teams</td></tr><tr><td>/mt setmaxplayer</td><td>&#x3C;tournament> &#x3C;n></td><td>ut.setmaxplayer</td><td>Set max players per team</td></tr><tr><td>/mt reload</td><td></td><td>ut.reload</td><td>Reload settings.yml</td></tr></tbody></table>


# Roadmap (v3)

What is coming in Modern Tournament v3 — new tournament formats, parallel matches across arenas, crash recovery and a rebuilt live bracket.

{% hint style="info" %}
This page describes work in progress. Every step under [Order of delivery](#order-of-delivery) has landed, apart from the items still marked as planned below; features and timing may change.
{% endhint %}

v3 is a ground-up rework of how the plugin and the online bracket talk to each other, plus the tournament formats that rework unlocks.

## Why

Today the plugin and the website each build the bracket themselves. When the two copies disagree — or when a single network hiccup drops a match result — the tournament can get stuck mid-round with players left in the arena.

In v3 the website is the **single authority** for the bracket, and the plugin is the **executor** that runs the fights. There is one bracket, in one place.

## What changes for you

### Tournaments recover from network problems

Every result the plugin sends is queued on disk and retried until the server confirms it. If your server restarts mid-tournament, the plugin reconnects, catches up on what it missed and carries on from where it left off. A dropped request can no longer strand a tournament.

### The server never freezes on `/t start`

All network calls move off the main thread. Starting, resetting or reconfiguring a tournament is instant regardless of how the connection to the bracket service is behaving.

### New formats

| Format             | Status                                                     |
| ------------------ | ---------------------------------------------------------- |
| Single elimination | ✅ Proper seeding and a rendered 3rd-place match            |
| Double elimination | ✅ Winners & losers bracket, grand final with bracket reset |
| Round robin        | ✅ Everyone plays everyone; the table decides it            |
| Groups → playoffs  | ✅ Snake draw into groups, then a knockout                  |
| Swiss              | Planned                                                    |

### Best-of-N matches

✅ Configure the number of games per match, and a longer final than the rest of the tournament — Bo1 through the bracket and Bo3 or Bo5 for the last round. See [Best-of-N](/products/modern-tournament/configuration#best-of-n).

### Parallel matches

✅ Instead of running one duel at a time, v3 runs every ready match at once across your available arenas. A 32-team tournament goes from a long single-file queue to five quick rounds. See [Parallel matches](/products/modern-tournament/configuration#parallel-matches).

### Crash recovery

If your server goes down mid-tournament, the plugin reconnects on restart, works out which matches were left unfinished and either replays or cancels them — instead of leaving the tournament wedged forever. You can also cancel a stuck match from the website.

### Seeding you control

Randomly with a reproducible seed ✅ — the website draws the bracket from a stored seed, so the same tournament redrawn is the same tournament. Manual drag-and-drop seeding is still planned.

### Spectators

`/t spectate <tournament>` puts you in spectator mode and follows the live match.

### Reconnect grace

A player who disconnects mid-duel gets a configurable window to come back before it counts as a forfeit.

### Per-tournament kits and rules

Kits, timeouts and rules can be set per tournament — and even per round — instead of only per arena.

### Rewards

Run commands for each final placement, configurable in the config.

### Scheduling

Give a tournament a start time, or have it start automatically once N teams have registered.

## What changes on the website

* ✅ **Richer bracket** — every match shows how it was won (kill, timeout, forfeit, random draw) and the winner's remaining health, plus the losers bracket, grand final and group tables for the formats that have them.
* ✅ **Live state that survives a restart** — match highlighting is stored in the database rather than in memory.
* ✅ **Admin controls** — override a result when something goes wrong and void a match left hanging by a server crash, both recorded in an audit log, plus a panel showing which server holds the tournament. See [When a tournament goes wrong](/products/modern-tournament/website#when-a-tournament-goes-wrong).
* ✅ **Stream overlay** — a transparent view sized for OBS browser sources, showing what is being fought now. See [Stream overlay](/products/modern-tournament/website#stream-overlay).
* ✅ **Player records** — tournaments entered and won, match record and win rate, counted from the matches themselves. See [Player records](/products/modern-tournament/website#player-records).
* ✅ **Webhooks** — tournament and match events posted to your own endpoint, signed and retried until they land. See [Webhooks](/products/modern-tournament/website#webhooks).
* ✅ **API tokens** — named, scoped, revocable keys replacing the single permanent API key. See [API tokens](/products/modern-tournament/website#api-tokens).
* **Creating tournaments from the dashboard** — planned; they are created in-game today.
* **Drag-and-drop seeding** — planned.
* **Discord integration** — planned. Webhooks cover it today for anyone willing to write a relay.
* **Embeddable bracket** — planned.

## Compatibility

* **v3 replaces the old API rather than sitting alongside it.** A `2.x` plugin cannot talk to the current website, and a current plugin cannot talk to an older one — upgrade them together.
* The plugin and the service negotiate a protocol version on connect, and tell you plainly if they are mismatched.
* **v3 requires Paper 1.20.5 or newer and a Java 21 runtime.** If you are on an older server, the `2.x` line continues to receive fixes — it just will not gain the v3 features on this page.
* A tournament is run by one Minecraft server at a time. Running matches of the same tournament across several servers is not planned.

## Order of delivery

1. ✅ **Stability fixes** — no more main-thread freezes, no more tournaments stuck on a dropped request, live bracket state persisted.
2. ✅ **Foundations** — rebuilt project structure and test coverage on both sides. The plugin now needs Java 21 and Paper 1.21.
3. ✅ **Single bracket engine** — the website is authoritative, and is now the only implementation; see [Bracket sync](/products/modern-tournament/configuration#bracket-sync).
4. ✅ **Durable sync** — offline queue, retries, crash recovery, and live push over a WebSocket. See [What step 4 changed](#what-step-4-changed).
5. ✅ **Formats & parallel matches** — double elimination, groups, best-of-N, multi-arena. See [What step 5 changed](#what-step-5-changed).
6. ✅ **Website features** — API tokens, admin controls, overlay, records, webhooks. See [What step 6 changed](#what-step-6-changed).

### What step 6 changed

**Your API key is no longer one permanent key.** Create a named token per server, carrying only the permissions that server needs, and revoke it on its own without touching anything else. Only a hash is stored, so a token is shown once and cannot be recovered. The account's original key still works and still carries everything; retire it by moving your servers onto tokens and then refreshing it. See [API tokens](/products/modern-tournament/website#api-tokens).

**A tournament that goes wrong can be fixed.** Set a winner by hand, or void a match a crashed server left hanging, both from the dashboard with a reason that is recorded. Changing a match replays the bracket, so everything that followed from it is recomputed and you are told what could no longer have happened. The same panel names the server holding the tournament, which is the answer to "why will it not start". See [When a tournament goes wrong](/products/modern-tournament/website#when-a-tournament-goes-wrong).

**Every tournament has a stream overlay.** A transparent browser source showing the matches being fought and the champion when there is one, on the same live connection as the bracket page. See [Stream overlay](/products/modern-tournament/website#stream-overlay).

**Player records across your tournaments**, counted from the matches every time rather than kept as totals — so a corrected result is reflected at once and nothing can drift.

**Webhooks.** Tournament and match events posted to your own endpoint, signed so you can verify them and retried for a day before being given up on. See [Webhooks](/products/modern-tournament/website#webhooks).

### What step 5 changed

**There are four formats instead of one.** Double elimination, round robin and groups → playoffs join single elimination, and `settings.format.type` picks between them. The plugin tells the website which one it means when the tournament starts, so both ends draw the same tournament rather than each assuming.

Under the hood this is one change, not four. A tournament used to advance a winner by arithmetic — the next round, at half the position — which is correct for exactly one shape of bracket. Now the draw states, for every match, where its winner goes and where its loser goes, and one tested engine follows those instructions whatever the format. A losers bracket is not a special case; it is the same engine reading a different set of arrows.

**A match can be more than one game.** `best_of` and `best_of_final` set how many, and the same two teams play until one has a majority. Each game is reported as it finishes, so the online bracket shows the score as it stands. A game nobody turns up for settles the whole match rather than dragging a decided result through two more countdowns.

**Matches are addressed by bracket as well as by round.** Winners round 2 and losers round 2 are different matches between different teams, so a round and a position no longer name one on their own. If you drive the API yourself, the result and state endpoints moved from `/matches/{round}/{order}` to `/matches/{bracket}/{round}/{order}`.

**Your arenas are all used.** `max_parallel_matches` caps how many matches run at once, each in its own arena. It defaults to `1`, which is exactly the old behaviour, so nothing changes until you raise it.

**The bracket page shows the whole tournament.** The losers bracket, the grand final and group tables are rendered alongside the main tree instead of being left out of it.

### What step 4 changed

**A result can no longer be lost.** The plugin writes each one to disk before it tries to send it, and keeps retrying until the website confirms it — through an outage, through a restart, for as long as it takes. Sending the same result twice is safe, so a retry after a timeout cannot duplicate or contradict anything. Previously a failed send was logged and dropped, and the online bracket quietly fell behind.

**A crashed server no longer wedges a tournament.** A tournament is executed by one server, which claims it on start. If that server dies, the match it was running is left unfinished; when it comes back it claims the tournament again, that match is reopened and played, and the server tells you in the log which ones those were. A server that has been superseded — because another one took over, or because it restarted — is refused if it tries to report an old result, so a bracket can never become a mixture of two runs.

If a tournament is held by a server that is gone for good, it can be taken over deliberately from the other machine.

**A spectator who loses connection catches up.** The bracket page holds a WebSocket to the site, and a reconnect is sent exactly what it missed, in order, instead of silently sitting on a stale bracket until someone reloads.

{% hint style="warning" %}
**If you host the website yourself**, the live connection needs one change to your reverse proxy: `/api/v3/gateway` must forward the WebSocket upgrade headers, and wants a generous `proxy_read_timeout` so a quiet bracket is not dropped every minute. The nginx block is in `server/server.ts` in the web repository. The site is also started with `npm start` as before, but that now runs a small wrapper around Next rather than `next start` — nothing else about the deployment changes.
{% endhint %}

### What step 3 changed

The website now draws the bracket once, when a tournament starts, and stores it — every match of every round, with byes already awarded. It used to rebuild the bracket from the current team list every time anyone asked for it, which meant a tournament's shape could change under it while it was being played.

Recording a result and moving the winner into the next round now happen together, so the bracket is never seen with a decided match whose winner has not appeared yet. A result the bracket cannot justify — a winner who was not in the match, a second different winner for a match already decided — is refused rather than stored.

Each tournament also keeps a numbered log of everything that has happened to it, which is what step 4 builds on: a plugin or a browser that was disconnected can ask for what it missed instead of refetching and hoping.


# NetworkEconomy

NetworkEconomy is a cross-server, Vault-compatible economy that keeps one single balance per player across every server of your network.

{% content-ref url="/pages/RTbqr6wxnSK2PyCAA4tQ" %}
[Features](/products/networkeconomy/features)
{% endcontent-ref %}

{% content-ref url="/pages/5RFCbmW4Zfdgq1Th3Ier" %}
[Architecture](/products/networkeconomy/architecture)
{% endcontent-ref %}

{% content-ref url="/pages/0mM35jOo2KNamrXrspkF" %}
[Installation](/products/networkeconomy/installation)
{% endcontent-ref %}

{% content-ref url="/pages/Dn6BVZ3DhkzBCnQ8LORe" %}
[Configuration](/products/networkeconomy/configuration)
{% endcontent-ref %}

{% content-ref url="/pages/8508BwhDhaojPItXEqHU" %}
[Currencies](/products/networkeconomy/currencies)
{% endcontent-ref %}

{% content-ref url="/pages/qAkKV6dV6FvvdCICZGSG" %}
[Command & Permissions](/products/networkeconomy/command-and-permissions)
{% endcontent-ref %}

{% content-ref url="/pages/oHsBmZJId9SGojnAROrm" %}
[Troubleshooting](/products/networkeconomy/troubleshooting)
{% endcontent-ref %}

{% content-ref url="/pages/JT14wVabJHI0RRH59t1W" %}
[For Developers](/products/networkeconomy/for-developers)
{% endcontent-ref %}


# Features

NetworkEconomy is designed to be **base infrastructure** for a multi-server network (BungeeCord / Velocity). Every server shares the same player balance in real time, so a player can hop between servers without a desynced balance — and without any way to duplicate currency by switching servers quickly.

## One Balance Across The Whole Network

A single MySQL / MariaDB database is the **authoritative** store for every balance. Servers never own a copy of a balance they can write back later; they read through to the database and keep only a short-lived local cache for display purposes.

That means:

* `/balance` on the lobby and `/balance` on survival always show the same number.
* `/pay` works between two players who are on **different servers**.
* `/baltop` ranks the entire network, not just the server you ran it on.
* A player who leaves one server and joins another instantly sees the same balance.

## Duplication-Proof By Design

The usual "load balance on join, save it on quit" approach is exactly what lets players duplicate money by rejoining on another server, and lets two servers overwrite each other's writes. NetworkEconomy never does read-modify-write: every money movement is a single atomic, relative SQL statement, and withdrawals check sufficiency *inside* the same statement that debits the account.

Two servers withdrawing at the same instant therefore cannot overdraw — the second one simply affects zero rows and reports `INSUFFICIENT_FUNDS`. See [architecture.md](/products/networkeconomy/architecture) for the full explanation.

## Real-Time Sync Over Redis

Redis pub/sub broadcasts every balance change to every node the moment it is committed, so local caches stay coherent in real time. Broadcasts carry a monotonic `version`, making them **idempotent and immune to out-of-order delivery**: a late message carrying an older version is dropped.

{% hint style="info" %}
Redis is optional. With it disabled the plugin still behaves correctly — the database remains authoritative — and caches are refreshed on a timer instead of instantly.
{% endhint %}

## Full Vault Compatibility

NetworkEconomy registers itself as the Vault `Economy` provider at the highest service priority, so **every existing Vault-based plugin works unchanged** — shops, jobs, crates, kits, anything. They automatically become network-wide economy plugins without a single change.

The bridge exposes the default currency, reports its symbol, name and fractional digits to Vault, and does not implement banks.

## Multi-Currency

Define as many currencies as you like (for example `coins` and `gems`), each with its own display name, symbol, symbol position, decimal precision, starting balance and whether players may `/pay` it. One currency is flagged as the default and is the one exposed to Vault; the rest are reachable through the commands and the API.

## Persistent Transaction Log

Every change is written to an append-only audit table: who, which currency, what type, how much, the resulting balance, the counterparty, a reason string, **which server it happened on**, and when. Nothing is ever overwritten, so the log doubles as a fraud and refund trail.

Players and staff browse it in-game through a paginated **transaction history GUI** (`/transactions`), where each entry is colour-coded by type — emerald for incoming, redstone for outgoing, paper for admin `set`, barrier for `reset`.

## Async API For Developers

A clean, fully asynchronous API (`CompletableFuture`-based) plus two Bukkit events — a cancellable `PreTransactionEvent` and an informational `BalanceUpdateEvent` that also fires for changes made on **other** servers. A no-I/O cached read is available for scoreboards and tab lists. See [for-developers.md](/products/networkeconomy/for-developers).

## Quality-Of-Life

* Amount suffixes: `/pay Notch 1.5k`, `10m`, `2b` all work, and commas are ignored.
* Tab completion for player names and currency ids everywhere, filtered to payable currencies on `/pay`.
* Optional `/pay` tax, minimum amount and self-payment block.
* Messages support both legacy `&` colour codes and modern MiniMessage tags.
* `/baltop` results are cached briefly to keep the query load off the database.


# Architecture

This page explains **why** NetworkEconomy is safe against balance duplication and lost writes. If you only want to install it, go to [installation.md](/products/networkeconomy/installation).

## The problem it solves

The naive way to write an economy plugin is:

1. On join, read the player's balance from the database into a local field.
2. Mutate that field while they play.
3. On quit, write the field back.

On a single server this mostly works. On a network it is broken in two directions:

* **Duplication.** A player with 1,000 coins spends them on the lobby, then quickly rejoins on survival — which still holds the stale cached 1,000 — and quits, writing 1,000 back over the correct value.
* **Lost writes.** Two servers both hold a cached balance and both save it. The last save wins and the other server's earnings vanish.

## The four rules

### 1. The database is the only authority

There is exactly one source of truth: the `balances` table in MySQL / MariaDB. Local caches are **read-through only** — they are never written back as if they were authoritative. A cache miss is just a database read; a cache being wrong can only ever cause a briefly stale *display*, never a wrong *transaction*, because every mutation is evaluated by the database itself.

### 2. All money movements are atomic relative operations

No operation ever reads a balance into Java, changes it, and writes it back. Instead:

| Operation       | How it is executed                                                                                        |
| --------------- | --------------------------------------------------------------------------------------------------------- |
| **Deposit**     | `UPDATE balances SET balance = balance + ? WHERE uuid = ? AND currency = ?`                               |
| **Withdraw**    | `UPDATE balances SET balance = balance - ? WHERE uuid = ? AND currency = ? AND balance >= ?`              |
| **Transfer**    | Both sides inside a single transaction, rows locked with `SELECT ... FOR UPDATE` in a deterministic order |
| **Set / Reset** | `UPDATE balances SET balance = ?` inside a transaction, then read back                                    |

The withdrawal is the important one. The sufficiency check (`balance >= ?`) and the debit are **a single statement**, so they cannot be interleaved. If twenty servers try to withdraw 10 from a balance of 100 at the same instant, exactly ten succeed; the rest affect **zero rows** and return `INSUFFICIENT_FUNDS`. There is no window in which two servers can both "see enough money" and both spend it.

Transfers lock both rows in a deterministic order, which is what prevents two opposite transfers (`A → B` and `B → A`) from deadlocking each other.

{% hint style="info" %}
Balances are stored as `DECIMAL(20,4)` and handled as `BigDecimal` in Java — never `double` — so money arithmetic is exact and no rounding error can accumulate.
{% endhint %}

### 3. A monotonic version column makes sync idempotent

Every balance row carries a `version` counter that is incremented on every change. Redis broadcasts carry `(balance, version)`, and the cache applies an incoming update **only if its version is newer** than what it already holds.

This is what makes the sync layer robust rather than merely fast:

* Messages that arrive **out of order** are harmless — the older one is dropped.
* Messages that arrive **twice** are harmless — applying them is idempotent.
* A message that is **lost entirely** costs nothing but a brief staleness, resolved by the next read or the cache expiry.

### 4. Redis pub/sub keeps caches coherent in real time

When a balance changes, the originating node publishes a compact message on the configured channel. Every other node applies it to its cache (subject to the version guard) and fires `BalanceUpdateEvent` with `isRemote() == true`. The publishing node ignores its own messages by matching `server-id`, which is why that value **must be unique per server**.

If Redis is disabled or fails to connect, the plugin logs it and falls back to re-reading online players' balances from the database every `cache.fallback-refresh-seconds`. Correctness is unaffected — only the propagation delay changes.

## Request flow

```
/pay on survival-2
      │
      ▼
PreTransactionEvent (async, cancellable)
      │
      ▼
SqlStorage.transfer()  ── single DB transaction, FOR UPDATE in fixed order
      │                    (atomic; either both sides apply or neither)
      ▼
transactions table  ── append-only audit row per side
      │
      ├─► local cache updated (version-guarded)
      ├─► BalanceUpdateEvent (local)
      └─► Redis publish ──► every other node
                                 │
                                 ├─► cache updated if version is newer
                                 └─► BalanceUpdateEvent (remote = true)
```

## The Vault trade-off

Vault's `Economy` API is **synchronous** — it must return a number immediately. NetworkEconomy's core is asynchronous. The bridge therefore blocks briefly on the underlying async operation, bounded by a 5-second timeout, so that it can return an accurate response rather than a guess.

Reads are cheaper than they look: the bridge serves `getBalance` from the local cache when the player is cached (the normal case for online players) and only falls back to a blocking database read for offline players.

{% hint style="warning" %}
Latency-sensitive plugins — anything calling the economy in a tight loop or on every tick — should use the native async API instead of the Vault bridge. See [for-developers.md](/products/networkeconomy/for-developers).
{% endhint %}

## Startup order

The plugin wires itself in a fixed order, because parts of it depend on each other:

`config` → `storage` (connect + apply schema) → `cache` → `service` → `Redis` → `Vault` → `commands & listeners`

The service is created *before* Redis so the Redis message handler can call back into it, and Redis is then injected into the service. If the database cannot be reached, or the currency configuration is invalid, the plugin **disables itself** rather than running in a state where balances could be wrong.

## Connection handling & shading

Connection handling is built on [DatabaseProvider](https://github.com/ohAleee/DatabaseProvider): pooled MariaDB via HikariCP, Redis via Lettuce with a dedicated pub/sub connection, and SQL schema loading with `{prefix}` substitution.

All bundled libraries — DatabaseProvider, HikariCP, the MariaDB driver, Lettuce, Netty, Reactor — are relocated under `com.ohalee.networkeconomy.lib`. Relocating Netty in particular is **required, not cosmetic**: Paper ships its own Netty and Lettuce needs a newer one, so an unrelocated build would clash with the server at runtime.

## Project layout

```
com.ohalee.networkeconomy
├── NetworkEconomyPlugin          # bootstrap / wiring
├── api/                          # public API (stable surface for other plugins)
│   ├── NetworkEconomyAPI         #   async service interface
│   ├── NetworkEconomyProvider    #   static accessor
│   ├── Currency, EconomyResult, TransactionRecord, BalanceEntry, TransactionType
│   └── event/                    #   PreTransactionEvent, BalanceUpdateEvent
├── core/                         # service implementation
│   ├── EconomyServiceImpl        #   orchestrates storage + cache + redis + events
│   ├── CurrencyManager
│   └── cache/                    #   version-guarded in-memory cache
├── storage/                      # persistence (on top of DatabaseProvider)
│   ├── EconomyStorage, SqlStorage (atomic SQL), SchemaResource
│   └── MutationResult, TransferResult, BalanceRow
├── sync/                         # Redis pub/sub (RedisManager, BalanceMessage)
├── vault/                        # VaultEconomyProvider bridge
├── command/                      # /balance /pay /baltop /eco /transactions
├── gui/                          # transaction-history GUI
├── listener/                     # join/quit cache lifecycle
├── config/                       # PluginConfig, Messages
└── util/                         # Text, NumberUtil
```


# Installation

## Requirements

|              |                                                                     |
| ------------ | ------------------------------------------------------------------- |
| **Server**   | Paper / Spigot 1.20.5+ or 1.21.x (`api-version: 1.20`)              |
| **Java**     | 21                                                                  |
| **Database** | MySQL or MariaDB — **required**, shared by every server             |
| **Redis**    | Optional, strongly recommended for networks                         |
| **Vault**    | Optional (`softdepend`) — only needed for third-party Vault plugins |

## Steps

1. Drop the `NetworkEconomy` jar into `plugins/` on **every** server that should share the economy.
2. Start the server once. It will generate `config.yml` and then disable itself if it cannot reach a database — that is expected on the first run.
3. Fill in the `storage` section with your MySQL / MariaDB details, and the `redis` section if you are using Redis.
4. Set a **unique `server-id`** on each server (see below).
5. Start the server again. The schema is created automatically on first successful connection.

```yaml
server-id: "lobby-1"

storage:
  host: "127.0.0.1"
  port: 3306
  database: "networkeconomy"
  username: "root"
  password: "changeme"

redis:
  enabled: true
  host: "127.0.0.1"
  port: 6379
```

{% hint style="danger" %}
`server-id` **must be different on every server of the network.** It identifies the origin server in transaction logs, and it is how a node recognises and ignores its own Redis broadcasts. Two servers sharing an id will ignore each other's balance updates.
{% endhint %}

{% hint style="warning" %}
Point every server at the **same database**. A per-server database defeats the entire purpose — and if two servers use different databases while sharing a Redis channel, they will apply each other's balances on top of unrelated rows.
{% endhint %}

## Database setup

Create an empty database and a user that can create tables in it:

```sql
CREATE DATABASE networkeconomy CHARACTER SET utf8mb4;
CREATE USER 'networkeconomy'@'%' IDENTIFIED BY 'a-strong-password';
GRANT ALL PRIVILEGES ON networkeconomy.* TO 'networkeconomy'@'%';
FLUSH PRIVILEGES;
```

The three tables (`ne_accounts`, `ne_balances`, `ne_transactions`) are created on startup by the bundled `schema.sql`; `{prefix}` is substituted with your configured `table-prefix`. There is nothing to import by hand.

{% hint style="info" %}
Keep the connection pool small — `storage.pool.maximum-pool-size: 10` is plenty. Economy queries are short, and the pool size also determines the plugin's worker-thread count.
{% endhint %}

## Redis setup

Redis is only used for pub/sub cache invalidation — **no balances are stored in it**, so it needs no persistence and losing it never loses money.

```yaml
redis:
  enabled: true
  host: "127.0.0.1"
  port: 6379
  username: ""      # only for ACL users
  password: ""      # empty for no auth
  database: 0
  channel: "networkeconomy:updates"
```

Every server must use the **same `channel`**. If Redis fails to connect at startup the plugin logs a warning and continues in DB-only mode, refreshing caches on a timer instead.

## Vault

If Vault is installed, NetworkEconomy registers itself as the economy provider at the highest priority and logs `Registered NetworkEconomy as the Vault economy provider.` Existing Vault plugins then work unchanged and automatically become network-wide.

{% hint style="warning" %}
Remove any other economy plugin (EssentialsX Economy, CMI economy, …) before installing NetworkEconomy. Two economy providers on one server means Vault plugins may read balances from one and write to the other.
{% endhint %}

## Verifying the setup

* Console shows `NetworkEconomy enabled (server-id: ...).`
* `/balance` returns your starting balance (100 coins by default).
* `/eco give <you> 500` on **server A**, then `/balance` on **server B** — the new value should be visible immediately with Redis enabled.
* `/transactions` shows the `give` you just made, with server A's `server-id` on the entry.

If any of that fails, see [troubleshooting.md](/products/networkeconomy/troubleshooting).

## Building from source

Requires JDK 21.

```bash
./gradlew build
# -> build/libs/NetworkEconomy-<version>.jar   (shaded, relocated dependencies)
```


# Configuration

```yaml
# =====================================================================
#  NetworkEconomy configuration
#  Cross-server, Vault-compatible economy for Paper/Spigot networks.
# =====================================================================

# A unique identifier for THIS server inside the network.
# It is used as the "source server" for transaction logs and to ignore
# Redis messages published by this same node. Must be different on every
# server of the network.
server-id: "lobby-1"

# ---------------------------------------------------------------------
# Storage: authoritative persistent store (MySQL / MariaDB).
# This is the single source of truth. All balances live here.
# ---------------------------------------------------------------------
storage:
  host: "127.0.0.1"
  port: 3306
  database: "networkeconomy"
  username: "root"
  password: "changeme"
  pool:
    # HikariCP pool sizing. Keep it small; economy queries are short.
    maximum-pool-size: 10
    minimum-idle: 2
  # Prefix for all tables created by the plugin (see schema.sql).
  table-prefix: "ne_"

# ---------------------------------------------------------------------
# Redis: pub/sub used to keep local caches coherent in real time.
# Optional but STRONGLY recommended for multi-server setups.
# ---------------------------------------------------------------------
redis:
  enabled: true
  host: "127.0.0.1"
  port: 6379
  username: ""      # leave empty unless your Redis uses ACL users
  password: ""      # leave empty for no auth
  database: 0
  # Channel used for balance-change broadcasts across the network.
  channel: "networkeconomy:updates"
  pool:
    minimum-size: 2
    maximum-size: 8

# ---------------------------------------------------------------------
# Local in-memory cache for online players.
# ---------------------------------------------------------------------
cache:
  expire-after-seconds: 300
  fallback-refresh-seconds: 30

# ---------------------------------------------------------------------
# Currencies. The first entry is the DEFAULT currency and the one exposed
# through the Vault Economy API.
# ---------------------------------------------------------------------
currencies:
  coins:
    display-name: "Coin"
    display-name-plural: "Coins"
    symbol: "$"
    symbol-before: true
    decimals: 2
    starting-balance: 100.0
    payable: true
    default: true
  gems:
    display-name: "Gem"
    display-name-plural: "Gems"
    symbol: "♦"
    symbol-before: false
    decimals: 0
    starting-balance: 0.0
    payable: false
    default: false

# ---------------------------------------------------------------------
# Command behaviour.
# ---------------------------------------------------------------------
commands:
  pay:
    minimum-amount: 0.01
    deny-self: true
    tax-percent: 0.0
  baltop:
    entries-per-page: 10
    cache-seconds: 60

# ---------------------------------------------------------------------
# Transaction history GUI.
# ---------------------------------------------------------------------
gui:
  history:
    title: "&8Transaction History"
    rows: 6
    entries-per-page: 45

# ---------------------------------------------------------------------
# Messages. Supports legacy '&' colour codes and MiniMessage tags.
# ---------------------------------------------------------------------
messages:
  prefix: "&6&lNE &8» &r"
  no-permission: "&cYou do not have permission to do that."
  player-only: "&cThis command can only be used by players."
  player-not-found: "&cPlayer &e{player}&c has never joined the network."
  invalid-amount: "&cPlease provide a valid, positive amount."
  invalid-currency: "&cUnknown currency: &e{currency}&c."
  balance-self: "&7Your balance: &a{balance}"
  balance-other: "&7{player}'s balance: &a{balance}"
  pay-sent: "&aYou sent &e{amount}&a to &e{receiver}&a."
  pay-received: "&aYou received &e{amount}&a from &e{sender}&a."
  pay-insufficient: "&cYou do not have enough {currency}."
  pay-self: "&cYou cannot pay yourself."
  eco-give: "&aGave &e{amount}&a to &e{player}&a. New balance: &e{balance}"
  eco-take: "&aTook &e{amount}&a from &e{player}&a. New balance: &e{balance}"
  eco-set: "&aSet &e{player}&a's balance to &e{balance}&a."
  eco-reset: "&aReset &e{player}&a's balance to the starting amount."
  error-generic: "&cAn internal error occurred. Please contact staff."

# Enable verbose logging for debugging (SQL / Redis traffic).
debug: false
```

## General

<table><thead><tr><th width="230">Key</th><th>Description</th></tr></thead><tbody><tr><td>server-id</td><td>Unique identifier for this server. Written to every transaction row as the origin server, and used to ignore this node's own Redis broadcasts. <strong>Must differ on every server</strong></td></tr><tr><td>debug</td><td>Verbose logging of SQL and Redis traffic</td></tr></tbody></table>

## storage

<table><thead><tr><th width="230">Key</th><th>Description</th></tr></thead><tbody><tr><td>host / port</td><td>MySQL / MariaDB address</td></tr><tr><td>database</td><td>Database name — must already exist</td></tr><tr><td>username / password</td><td>Credentials; the user needs table-creation rights on first start</td></tr><tr><td>pool.maximum-pool-size</td><td>Maximum HikariCP connections. Also determines the plugin's worker-thread count (minimum 2). Keep it small</td></tr><tr><td>pool.minimum-idle</td><td>Connections kept open while idle</td></tr><tr><td>table-prefix</td><td>Prefix applied to all three tables. Substituted for <code>{prefix}</code> in the schema</td></tr></tbody></table>

{% hint style="danger" %}
Changing `table-prefix` after first start points the plugin at a **new, empty set of tables** — the old balances are still in the database but no longer read. Only change it before going live.
{% endhint %}

## redis

<table><thead><tr><th width="230">Key</th><th>Description</th></tr></thead><tbody><tr><td>enabled</td><td>Turn real-time sync on or off. When off, caches refresh on a timer instead</td></tr><tr><td>host / port</td><td>Redis address</td></tr><tr><td>username</td><td>Only needed for Redis ACL users; leave empty otherwise</td></tr><tr><td>password</td><td>Leave empty for a Redis with no auth</td></tr><tr><td>database</td><td>Redis logical database index</td></tr><tr><td>channel</td><td>Pub/sub channel for balance broadcasts. <strong>Must be identical on every server</strong></td></tr><tr><td>pool.minimum-size / maximum-size</td><td>Lettuce connection-pool sizing. A dedicated connection is used for the subscription</td></tr></tbody></table>

## cache

<table><thead><tr><th width="230">Key</th><th>Description</th></tr></thead><tbody><tr><td>expire-after-seconds</td><td>How long a cached balance is trusted before a background refresh is forced. Only matters when Redis is off or a message was missed — with Redis on, this can stay high</td></tr><tr><td>fallback-refresh-seconds</td><td>With Redis disabled, how often online players are re-synced from the database. Ignored when Redis is enabled</td></tr></tbody></table>

{% hint style="info" %}
The cache only ever affects how fresh a *displayed* number is. Transactions are always evaluated by the database, so a stale cache can never cause a wrong deposit, withdrawal or transfer.
{% endhint %}

## currencies

Covered in detail on [currencies.md](/products/networkeconomy/currencies).

## commands

<table><thead><tr><th width="230">Key</th><th>Description</th></tr></thead><tbody><tr><td>pay.minimum-amount</td><td>Smallest amount accepted by <code>/pay</code></td></tr><tr><td>pay.deny-self</td><td>Block players from paying themselves</td></tr><tr><td>pay.tax-percent</td><td>Percentage removed from the transferred amount. <code>0.0</code> disables the tax</td></tr><tr><td>baltop.entries-per-page</td><td>Rows shown per <code>/baltop</code> page</td></tr><tr><td>baltop.cache-seconds</td><td>How long a computed <code>/baltop</code> page is reused before being queried again</td></tr></tbody></table>

{% hint style="warning" %}
With `pay.tax-percent` above zero, `/pay` cannot use a single atomic transfer: the sender is debited the full amount and the receiver credited the net, and the difference leaves circulation. Leave the tax at `0.0` if you want transfers to stay a single all-or-nothing database transaction.
{% endhint %}

## gui.history

<table><thead><tr><th width="230">Key</th><th>Description</th></tr></thead><tbody><tr><td>title</td><td>Inventory title of the history GUI</td></tr><tr><td>rows</td><td>Inventory height, clamped to 1–6</td></tr><tr><td>entries-per-page</td><td>Transactions per page. Leave room for the navigation row — with <code>rows: 6</code>, <code>45</code> fills the top five rows and leaves the last for navigation</td></tr></tbody></table>

## messages

Every value supports **legacy `&` colour codes** *and* **MiniMessage**. A string containing `&` is parsed as legacy; otherwise it is parsed as MiniMessage — so `&aHello` and `<green>Hello</green>` both work, but don't mix the two in one line.

`prefix` is prepended to command feedback automatically. Available placeholders:

`{player}` `{amount}` `{currency}` `{balance}` `{sender}` `{receiver}` `{reason}` `{page}` `{pages}`

Not every placeholder is meaningful in every message — `{receiver}` only makes sense in `pay-sent`, for instance. Unknown placeholders are left as-is rather than erased.

{% hint style="info" %}
The configuration is read once at enable. Changes to `config.yml` require a server restart (or a plugin reload) to take effect.
{% endhint %}


# Currencies

NetworkEconomy supports any number of currencies. Each one is an independent balance per player, stored as its own row in the `balances` table, with its own formatting and rules.

## Declaring a currency

Every entry under `currencies:` is a currency, and the config key is its **id** — the lowercase name used in commands and in the API.

```yaml
currencies:
  coins:
    display-name: "Coin"
    display-name-plural: "Coins"
    symbol: "$"
    symbol-before: true
    decimals: 2
    starting-balance: 100.0
    payable: true
    default: true
  gems:
    display-name: "Gem"
    display-name-plural: "Gems"
    symbol: "♦"
    symbol-before: false
    decimals: 0
    starting-balance: 0.0
    payable: false
    default: false
```

<table><thead><tr><th width="215">Key</th><th>Description</th></tr></thead><tbody><tr><td>display-name</td><td>Singular human-readable name</td></tr><tr><td>display-name-plural</td><td>Plural name. Used in messages like "You do not have enough Coins". Defaults to <code>display-name</code></td></tr><tr><td>symbol</td><td>Symbol used when formatting amounts</td></tr><tr><td>symbol-before</td><td><code>true</code> renders <code>$1,250.00</code>; <code>false</code> renders <code>1,250 ♦</code></td></tr><tr><td>decimals</td><td>Fractional digits kept. <code>0</code> makes the currency whole-number only. Amounts are rounded half-up to this precision</td></tr><tr><td>starting-balance</td><td>Balance given to a brand-new account, and the value <code>/eco reset</code> restores</td></tr><tr><td>payable</td><td>Whether players may send it with <code>/pay</code></td></tr><tr><td>default</td><td>Marks this as the default currency — the one used when no currency argument is given, and the only one exposed to Vault</td></tr></tbody></table>

## The default currency

Exactly one currency is the default: the first one flagged `default: true`, or simply the first entry if none is flagged.

The default currency is special in two ways:

* It is what every command uses when you omit the currency argument.
* It is the **only** currency visible through the Vault `Economy` API. Vault has no concept of multiple currencies, so third-party Vault plugins — shops, jobs, crates — always operate on this one.

{% hint style="warning" %}
Non-default currencies are reachable only through NetworkEconomy's own commands and API. A Vault-based shop plugin cannot charge in `gems`.
{% endhint %}

## Precision and rounding

Balances are stored as `DECIMAL(20,4)` in the database and handled as `BigDecimal` in Java, so arithmetic is exact — no floating-point drift. Amounts are normalised (rounded half-up) to the currency's `decimals` before being applied.

Because storage keeps 4 decimal places, `decimals` is a *presentation and input* precision, not a storage limit. Setting `decimals: 0` makes a currency behave as whole units: `/pay Notch 2.7 gems` becomes 3.

{% hint style="danger" %}
Lowering `decimals` on a live currency does not rewrite stored balances — existing fractional amounts stay in the database and simply stop being displayed. Decide precision before going live.
{% endhint %}

## Using a currency in commands

Every player-facing command takes an optional currency id as its last argument:

```
/balance                    # default currency, yourself
/balance gems               # gems, yourself
/balance Notch gems         # gems, another player
/pay Notch 250 coins        # explicit currency
/baltop 2 gems              # page 2 of the gems ranking
/eco give Notch 10 gems     # admin, explicit currency
```

Tab completion offers currency ids in each of those positions, and `/pay` only suggests currencies that are `payable`.

## Adding a currency later

Adding a new entry under `currencies:` is safe at any time. Balance rows are created on demand, so:

* Existing players get the new currency's `starting-balance` the first time it is touched.
* Nothing is migrated or rewritten.
* Remember to add the currency to the config on **every** server — a server that does not know an id will fall back to the default currency when resolving command arguments.

{% hint style="danger" %}
**Removing** a currency from the config does not delete its rows, but it makes those balances unreachable in game. If you also change which currency is `default`, every Vault plugin on the network silently starts reading and writing a different balance — announce that kind of change carefully.
{% endhint %}


# Command & Permissions

## Commands

<table><thead><tr><th width="215">Command</th><th width="200">Parameters</th><th width="225">Permission</th><th>Capabilities</th></tr></thead><tbody><tr><td>/balance</td><td>[player] [currency]</td><td>networkeconomy.balance</td><td>Show your own balance, or another player's. Aliases: <code>/bal</code>, <code>/money</code></td></tr><tr><td>/pay</td><td>&#x3C;player> &#x3C;amount> [currency]</td><td>networkeconomy.pay</td><td>Atomically send money to another player, on any server</td></tr><tr><td>/baltop</td><td>[page] [currency]</td><td>networkeconomy.baltop</td><td>Richest accounts across the whole network. Aliases: <code>/balancetop</code>, <code>/topbalance</code></td></tr><tr><td>/eco</td><td>&#x3C;give|take|set|reset> &#x3C;player> [amount] [currency]</td><td>networkeconomy.admin</td><td>Administrative balance management. Aliases: <code>/economy</code>, <code>/neco</code></td></tr><tr><td>/transactions</td><td>[player]</td><td>networkeconomy.history</td><td>Open the transaction history GUI. Aliases: <code>/history</code>, <code>/thistory</code></td></tr></tbody></table>

## Permissions

<table><thead><tr><th width="290">Permission</th><th width="110">Default</th><th>Capabilities</th></tr></thead><tbody><tr><td>networkeconomy.balance</td><td>true</td><td>View your own balance</td></tr><tr><td>networkeconomy.balance.others</td><td>op</td><td>View other players' balances with <code>/balance &#x3C;player></code></td></tr><tr><td>networkeconomy.pay</td><td>true</td><td>Send money with <code>/pay</code></td></tr><tr><td>networkeconomy.baltop</td><td>true</td><td>View the network balance top</td></tr><tr><td>networkeconomy.history</td><td>true</td><td>Open your own transaction history</td></tr><tr><td>networkeconomy.admin</td><td>op</td><td><code>/eco</code>, and viewing another player's history with <code>/transactions &#x3C;player></code></td></tr></tbody></table>

## Amount formats

Wherever an amount is accepted, these forms all work:

<table><thead><tr><th width="200">Input</th><th>Value</th></tr></thead><tbody><tr><td><code>250</code></td><td>250</td></tr><tr><td><code>19.99</code></td><td>19.99</td></tr><tr><td><code>1,500</code></td><td>1500 — commas are ignored</td></tr><tr><td><code>1.5k</code></td><td>1,500</td></tr><tr><td><code>10m</code></td><td>10,000,000</td></tr><tr><td><code>2b</code></td><td>2,000,000,000</td></tr></tbody></table>

Only strictly positive amounts are accepted; `0`, negatives and unparseable input are rejected with the `invalid-amount` message. The parsed value is then rounded half-up to the currency's `decimals`.

## /balance

```
/balance                # your own default-currency balance
/balance gems           # your own gems balance
/balance Notch          # another player, default currency
/balance Notch gems     # another player, specific currency
```

The first argument is treated as a currency if it matches a currency id **and** it is the only argument; otherwise it is treated as a player name. Offline players are resolved from the accounts table, so you can look up anyone who has ever joined the network — not just players on your server.

`/balance <player>` requires `networkeconomy.balance.others`.

## /pay

```
/pay Notch 500
/pay Notch 1.5k coins
```

The transfer runs as **one database transaction**: either both sides apply or neither does. It works regardless of which server the recipient is on, or whether they are online at all. If the recipient happens to be online on *your* server they receive the `pay-received` message immediately.

Behaviour is shaped by the `commands.pay` config section:

* `minimum-amount` — amounts below this are rejected.
* `deny-self` — paying yourself is blocked.
* `tax-percent` — a percentage is taken off the amount the receiver gets.

A currency with `payable: false` cannot be sent, and is not offered in tab completion.

{% hint style="info" %}
With a tax configured, the sender is debited the full amount and the receiver credited the net; the difference is removed from circulation. This split is what makes a taxed `/pay` two operations rather than one atomic transfer.
{% endhint %}

## /baltop

```
/baltop            # page 1, default currency
/baltop 3          # page 3
/baltop 2 gems     # page 2 of the gems ranking
```

The ranking is computed from the shared database, so it covers the **entire network**. Pages are cached for `commands.baltop.cache-seconds` (default 60) to keep repeated use from hammering the database, which means a very recent transaction may take up to that long to move someone in the list.

Players with no known name are shown by the first 8 characters of their UUID.

## /eco

```
/eco give Notch 1000
/eco take Notch 250 coins
/eco set Notch 0
/eco reset Notch
/eco reset Notch gems
```

<table><thead><tr><th width="140">Action</th><th>Effect</th></tr></thead><tbody><tr><td>give</td><td>Atomically adds the amount</td></tr><tr><td>take</td><td>Atomically removes the amount, failing if the funds are not there</td></tr><tr><td>set</td><td>Overwrites the balance to an absolute value</td></tr><tr><td>reset</td><td>Restores the currency's <code>starting-balance</code>. Takes no amount argument</td></tr></tbody></table>

All four work on offline players and log the acting staff member in the transaction's reason (`admin:<name>`), so admin adjustments are permanently attributable in the audit log. `take` reports insufficient funds rather than pushing a balance negative.

{% hint style="info" %}
Note the argument positions: `reset` has no amount, so its optional currency is the **third** argument (`/eco reset Notch gems`) while the others take it fourth (`/eco give Notch 10 gems`).
{% endhint %}

## /transactions

Opens a paginated GUI of the audit log, newest first. Each item is coloured by type:

<table><thead><tr><th width="200">Type</th><th width="150">Item</th><th>Meaning</th></tr></thead><tbody><tr><td>DEPOSIT / TRANSFER_IN</td><td>Emerald</td><td>Money came in</td></tr><tr><td>WITHDRAW / TRANSFER_OUT</td><td>Redstone</td><td>Money went out</td></tr><tr><td>SET</td><td>Paper</td><td>Balance overwritten by an admin</td></tr><tr><td>RESET</td><td>Barrier</td><td>Balance reset to the starting amount</td></tr></tbody></table>

Every entry shows the type, the resulting balance, the counterparty (for transfers), the reason, **which server the change happened on**, and the timestamp. Arrows in the bottom row page back and forward; the book in the middle shows the current page.

`/transactions <player>` requires `networkeconomy.admin` and works for offline players.


# Troubleshooting

## The plugin disables itself on startup

NetworkEconomy deliberately refuses to run in a state where balances could be wrong. There are two causes, both logged at `SEVERE`:

**`Failed to connect to the database. Disabling.`** The database is unreachable or the credentials are wrong. Check `storage.host` / `port`, that the database named in `storage.database` **already exists**, and that the user may connect from the server's IP. The cause is appended to the log line.

**`Invalid currency configuration`** The `currencies:` section is missing or empty. At least one currency must be declared — see [currencies.md](/products/networkeconomy/currencies).

## Balances differ between two servers

Work through these in order:

1. **Are both servers pointed at the same database?** Different `storage.database` values mean two independent economies.
2. **Do both have `redis.enabled: true` and the same `channel`?** A mismatched channel means broadcasts are published where nobody is listening.
3. **Do the two servers have different `server-id` values?** A node ignores broadcasts stamped with its own id — two servers sharing an id will ignore each other's updates entirely.
4. **Check the console for `Redis failed to start; running in DB-only mode.`** In that mode, updates propagate only every `cache.fallback-refresh-seconds`.

{% hint style="info" %}
A brief difference is normal and harmless: the caches only affect the number being *displayed*. Transactions are always evaluated against the database, so a stale display can never cause a wrong deposit, withdrawal or transfer.
{% endhint %}

## Redis failed to start

```
Redis failed to start; running in DB-only mode. Cause: ...
```

The plugin stays fully functional — the database remains authoritative — and falls back to refreshing online players' caches on a timer. Common causes:

* Wrong `host` / `port`, or Redis not running.
* A password set on Redis but left empty in the config (or the opposite).
* `redis.username` filled in for a Redis that does not use ACL users — leave it empty unless you actually have an ACL user.

Fix the connection and restart; there is no data to recover, since Redis only carries invalidation messages.

## Vault plugins do not see the economy

* Is Vault installed? Without it, the console logs `Vault not found; the Vault Economy bridge is disabled.`
* Is another economy plugin also registering a Vault provider? Remove it — with two providers, Vault plugins may read from one and write to the other.
* On success the console logs `Registered NetworkEconomy as the Vault economy provider.` Confirm with Vault's own `/vault-info`.

## A Vault plugin only sees one currency

That is expected. Vault has no concept of multiple currencies, so the bridge exposes only the **default** currency. Non-default currencies are reachable through NetworkEconomy's commands and API only.

## The server lags when a Vault plugin touches the economy

Vault's API is synchronous, so the bridge has to block briefly (bounded by a 5-second timeout) to return an accurate answer. Reads are served from the cache for online players, but a plugin that calls the economy in a tight loop or on every tick will feel it.

The fix is on the calling plugin's side: use the native async API. See [for-developers.md](/products/networkeconomy/for-developers).

## A player says they lost money

The transaction log is append-only and nothing is ever overwritten, so the truth is always recoverable:

* `/transactions <player>` shows every change, newest first, including which server it happened on and the reason string.
* Admin actions are stamped `admin:<staff name>`, `/pay` as `pay:<sender>`, and Vault-plugin activity as `vault`.

A reason of `vault` with no matching in-game action usually means a third-party plugin charged them.

## /baltop looks out of date

Pages are cached for `commands.baltop.cache-seconds` (default 60). Lower it if you want fresher rankings, but the cache exists to keep a frequently used command from repeatedly running a full sort over the balances table.

## Duplicate or missing history entries after a rejoin

Balances are loaded during the async pre-login event and cleared on quit. A player who reconnects rapidly may briefly have a stale cache entry — this affects only what the display shows, never the stored balance, and resolves at the next read or cache expiry.

## Enabling debug output

```yaml
debug: true
```

Logs SQL and Redis traffic verbosely. It is noisy, so turn it back off once you have what you need.

## Errors mentioning Netty or Lettuce

If you built the plugin yourself and see class-loading conflicts around Netty, the shading configuration was likely altered. All bundled libraries **must** stay relocated under `com.ohalee.networkeconomy.lib` — Paper ships its own Netty and Lettuce requires a newer one, so an unrelocated build clashes with the server at runtime. Likewise, `minimize` must stay off, since it would strip classes that are only reached reflectively (the JDBC driver, Netty handlers, Lettuce codecs).


# For Developers

NetworkEconomy exposes a stable, fully asynchronous API plus two Bukkit events. Everything in the `com.ohalee.networkeconomy.api` package is the public surface; anything outside it is internal and may change.

## Getting the API

Depend on NetworkEconomy in your `plugin.yml` so load order is guaranteed:

```yaml
depend: [NetworkEconomy]      # or softdepend if the hook is optional
```

Then grab the instance from your own `onEnable`:

```java
NetworkEconomyAPI economy = NetworkEconomyProvider.get();
```

With `softdepend`, guard the call:

```java
if (NetworkEconomyProvider.isAvailable()) {
    NetworkEconomyAPI economy = NetworkEconomyProvider.get();
}
```

The API is also registered with the Bukkit `ServicesManager`. `get()` throws `IllegalStateException` if NetworkEconomy is not loaded yet, and the provider is cleared when the plugin disables.

## Threading model

{% hint style="warning" %}
**Every** querying and mutating method is asynchronous and returns a `CompletableFuture`. Futures complete on an internal worker thread, **never** on the main server thread — so any Bukkit call inside a callback must be scheduled back onto the main thread.
{% endhint %}

```java
economy.getBalance(uuid).thenAccept(balance -> {
    // off the main thread!
    Bukkit.getScheduler().runTask(plugin, () -> {
        player.sendMessage("You have " + balance);
    });
});
```

This is deliberate: all operations are backed by an authoritative SQL store using atomic conditional updates, which is exactly what makes concurrent writes from different network nodes safe.

### Fast reads for UI

For scoreboards, tab lists and anything that needs a number *now*, use the cached read. It performs no I/O and returns empty if the player is not cached (normally meaning they are not on this server).

```java
Optional<BigDecimal> cached = economy.getCachedBalance(uuid, economy.getDefaultCurrency());
```

## Currencies

```java
Currency def = economy.getDefaultCurrency();          // the Vault-exposed one
Optional<Currency> gems = economy.getCurrency("gems"); // by id, case-insensitive
Collection<Currency> all = economy.getCurrencies();
```

`Currency` is immutable and equality is by id. Useful members:

<table><thead><tr><th width="255">Member</th><th>Description</th></tr></thead><tbody><tr><td>getId()</td><td>Lowercase config key</td></tr><tr><td>getDisplayName() / getDisplayNamePlural()</td><td>Human-readable names</td></tr><tr><td>getSymbol() / isSymbolBefore()</td><td>Symbol and its position</td></tr><tr><td>getDecimals()</td><td>Fractional digits</td></tr><tr><td>getStartingBalance()</td><td>Balance for new accounts</td></tr><tr><td>isPayable()</td><td>Whether <code>/pay</code> accepts it</td></tr><tr><td>isDefault()</td><td>Whether this is the Vault-exposed currency</td></tr><tr><td>normalize(BigDecimal)</td><td>Round to this currency's precision (half-up)</td></tr><tr><td>format(BigDecimal)</td><td>Format with grouping and symbol, e.g. <code>$1,250.00</code></td></tr></tbody></table>

## Reading balances

```java
CompletableFuture<BigDecimal> getBalance(UUID player);                       // default currency
CompletableFuture<BigDecimal> getBalance(UUID player, Currency currency);
CompletableFuture<Boolean>    has(UUID player, Currency currency, BigDecimal amount);
Optional<BigDecimal>          getCachedBalance(UUID player, Currency currency);
```

## Mutating balances

```java
CompletableFuture<EconomyResult> deposit (UUID player, Currency c, BigDecimal amount, String reason);
CompletableFuture<EconomyResult> withdraw(UUID player, Currency c, BigDecimal amount, String reason);
CompletableFuture<EconomyResult> set     (UUID player, Currency c, BigDecimal amount, String reason);
CompletableFuture<EconomyResult> transfer(UUID from, UUID to, Currency c, BigDecimal amount, String reason);
```

`reason` is stored on the transaction row (up to 128 characters) and shown in the history GUI. Use something identifiable — `"shop:diamond_sword"`, `"quest:reward:12"` — so the audit log stays useful.

```java
economy.withdraw(uuid, economy.getDefaultCurrency(),
        new BigDecimal("50.00"), "shop:diamond_sword")
    .thenAccept(result -> {
        if (result.isSuccess()) {
            BigDecimal now = result.newBalance();
        } else if (result.status() == EconomyResult.Status.INSUFFICIENT_FUNDS) {
            // not enough money — nothing was charged
        }
    });
```

{% hint style="danger" %}
Never implement your own "check then withdraw": `has(...)` followed by `withdraw(...)` is a race, because another server can spend the money in between. `withdraw` already performs the sufficiency check and the debit in a **single atomic statement** — just call it and handle `INSUFFICIENT_FUNDS`.
{% endhint %}

`transfer` moves money inside one database transaction: either both sides apply or neither does. Use it instead of a withdraw-then-deposit pair.

### EconomyResult

<table><thead><tr><th width="255">Member</th><th>Description</th></tr></thead><tbody><tr><td>status()</td><td>The outcome — see below</td></tr><tr><td>isSuccess()</td><td><code>true</code> when the operation was persisted</td></tr><tr><td>newBalance()</td><td>Balance after the operation, or the current balance on failure</td></tr><tr><td>message()</td><td>Optional detail, <code>null</code> on success</td></tr></tbody></table>

<table><thead><tr><th width="255">Status</th><th>Meaning</th></tr></thead><tbody><tr><td>SUCCESS</td><td>Completed and persisted</td></tr><tr><td>INSUFFICIENT_FUNDS</td><td>Not enough money for a withdrawal or transfer</td></tr><tr><td>ACCOUNT_NOT_FOUND</td><td>The player has never joined the network</td></tr><tr><td>CANCELLED</td><td>A <code>PreTransactionEvent</code> handler vetoed it</td></tr><tr><td>ERROR</td><td>Unexpected failure (SQL, connectivity, …)</td></tr></tbody></table>

## History and listings

```java
CompletableFuture<List<TransactionRecord>> getHistory(UUID player, int limit, int offset);
CompletableFuture<List<BalanceEntry>>      getTopBalances(Currency currency, int limit, int offset);
CompletableFuture<Optional<UUID>>          lookupUuid(String name);
```

`getHistory` returns newest first. `getTopBalances` returns highest first and covers the whole network. `lookupUuid` resolves a name case-insensitively from the accounts table, so it works for any player who has ever joined — no Mojang API call, no main-thread stall.

`TransactionRecord` is a record with: `id`, `player`, `currencyId`, `type`, `amount` (always positive), `balanceAfter`, `counterparty` (nullable, transfers only), `reason`, `serverId` and `timestamp`.

`BalanceEntry` is a record with `uuid`, `name` (nullable) and `balance`.

### TransactionType

<table><thead><tr><th width="255">Type</th><th>Meaning</th></tr></thead><tbody><tr><td>DEPOSIT</td><td>Credit added (Vault deposit, plugin reward, …)</td></tr><tr><td>WITHDRAW</td><td>Debit removed (Vault withdraw, shop purchase, …)</td></tr><tr><td>TRANSFER_OUT</td><td>Outgoing side of a player-to-player transfer</td></tr><tr><td>TRANSFER_IN</td><td>Incoming side of a player-to-player transfer</td></tr><tr><td>SET</td><td>Balance overwritten to an absolute value</td></tr><tr><td>RESET</td><td>Balance reset to the currency's starting amount</td></tr></tbody></table>

## Events

Both events are **asynchronous** — they fire off the main thread. Handlers must be thread-safe and must not touch the Bukkit API directly.

### PreTransactionEvent

Fired **before** a change is committed, and **cancellable**. Cancelling aborts the operation with `EconomyResult.Status.CANCELLED`.

```java
@EventHandler
public void onPre(PreTransactionEvent event) {
    if (event.getType() == TransactionType.WITHDRAW && isFrozen(event.getPlayer())) {
        event.setCancelled(true);
    }
}
```

Exposes `getPlayer()`, `getCurrency()`, `getType()`, `getAmount()` and `getReason()`.

### BalanceUpdateEvent

Fired **after** a change has been committed and cached — including changes that originated on **another** network node and arrived over Redis. Informational; it cannot be cancelled.

```java
@EventHandler
public void onChange(BalanceUpdateEvent event) {
    if (event.isRemote()) {
        // the change happened on a different server
    }
    BigDecimal delta = event.getNewBalance().subtract(event.getOldBalance());
}
```

Exposes `getPlayer()`, `getCurrency()`, `getOldBalance()`, `getNewBalance()`, `getType()` and `isRemote()`.

{% hint style="info" %}
`isRemote()` is the hook you want for keeping network-wide UI in sync: it lets you refresh a scoreboard when a player earns money on a server they are not currently on.
{% endhint %}

## Using Vault instead

If you already target Vault, nothing changes — NetworkEconomy registers as the Vault `Economy` provider at the highest priority and your plugin becomes network-wide for free. Two caveats:

* Vault exposes **only the default currency**.
* Vault's API is synchronous, so the bridge blocks briefly (5-second timeout) on the underlying async operation to return an accurate response. Banks are not supported and return `NOT_IMPLEMENTED`.

Prefer the native async API for anything latency-sensitive.

## Database schema

Read-only integrations can query the tables directly. All three use the configured `table-prefix` (default `ne_`) in place of `{prefix}`.

### `{prefix}accounts`

| Column       | Type          | Notes                                 |
| ------------ | ------------- | ------------------------------------- |
| `uuid`       | CHAR(36) (PK) | Player unique id                      |
| `name`       | VARCHAR(16)   | Last known name, indexed (`idx_name`) |
| `first_seen` | BIGINT        | Epoch millis                          |
| `last_seen`  | BIGINT        | Epoch millis                          |

### `{prefix}balances`

| Column     | Type          | Notes                             |
| ---------- | ------------- | --------------------------------- |
| `uuid`     | CHAR(36)      | Part of PK `(uuid, currency)`     |
| `currency` | VARCHAR(32)   | Currency id                       |
| `balance`  | DECIMAL(20,4) | Exact fixed-point balance         |
| `version`  | BIGINT        | Monotonic, bumped on every change |

Indexed by `idx_currency_balance (currency, balance)`, which is what makes `/baltop` cheap.

### `{prefix}transactions`

Append-only audit log — rows are never updated or deleted.

| Column          | Type          | Notes                                  |
| --------------- | ------------- | -------------------------------------- |
| `id`            | BIGINT (PK)   | Auto-increment                         |
| `uuid`          | CHAR(36)      | Affected account                       |
| `currency`      | VARCHAR(32)   | Currency id                            |
| `type`          | VARCHAR(16)   | `TransactionType` name                 |
| `amount`        | DECIMAL(20,4) | Always positive                        |
| `balance_after` | DECIMAL(20,4) | Balance immediately after the change   |
| `counterparty`  | CHAR(36)      | Other party for transfers, else `NULL` |
| `reason`        | VARCHAR(128)  | Caller-supplied reason                 |
| `server_id`     | VARCHAR(64)   | Origin node's `server-id`              |
| `created_at`    | BIGINT        | Epoch millis                           |

Indexed by `idx_uuid_time (uuid, created_at)`, `idx_currency` and `idx_created`.

{% hint style="danger" %}
Never write to `{prefix}balances` from outside the plugin. Balance changes must go through the API so they stay atomic, get a `version` bump, produce an audit row, and broadcast to the other nodes. Direct writes bypass all four.
{% endhint %}

## Redis message format

Broadcasts are compact pipe-delimited strings on the configured channel — no JSON dependency:

```
serverId|uuid|currency|balance|version|type
```

`reason` is intentionally omitted to keep broadcasts small. Receivers apply an update only if its `version` is newer than the cached one, which makes delivery idempotent and out-of-order-safe, and skip messages stamped with their own `serverId`.

## Testing notes

The plugin's own test suite runs the real storage layer against a **real MariaDB** and the real sync layer against a **real Redis** — no mocks, because the properties under test (atomicity, lock ordering, pub/sub delivery) only exist in a real engine.

```bash
./gradlew test
```

Both integration classes skip themselves cleanly when no server is reachable, so the build still succeeds without a database. Override the endpoints with `-Dne.test.host=…`, `-Dne.test.port=…`, `-Dne.test.redis.host=…`.

The concurrency tests hammer a single row from many threads as a stand-in for many network nodes, and are written so that replacing the atomic statements with naive read-modify-write logic makes them **fail** — verified by temporarily doing exactly that, which let 20 concurrent withdrawals of 10 all succeed against a balance of 100.


# UltimateGuilds

UltimateGuilds is a Hypixel style guild plugin. It has a distributed system for use on multiple servers and for integration with other plugins. Everything can be managed comfortably via GUI.

{% content-ref url="/pages/CdzFyGp4ilhS4k1h4Xds" %}
[Features](/products/ultimateguilds/features)
{% endcontent-ref %}

{% content-ref url="/pages/Xfaath3Bvnc1zlojQUeO" %}
[Installation](/products/ultimateguilds/installation)
{% endcontent-ref %}

{% content-ref url="/pages/pw310B5oBkbaFuWA14mN" %}
[Upgrading to 3.0](/products/ultimateguilds/upgrading)
{% endcontent-ref %}

{% content-ref url="/pages/VPg78fAP5Ccg1nmAAyAW" %}
[Configuration](/products/ultimateguilds/configuration)
{% endcontent-ref %}

{% content-ref url="/pages/uJhcLE1EJ56bzNgX4ok6" %}
[Ranks & Permissions](/products/ultimateguilds/ranks)
{% endcontent-ref %}

{% content-ref url="/pages/HVfLlOK0E3IXDcSfIJ1X" %}
[Progression](/products/ultimateguilds/progression)
{% endcontent-ref %}

{% content-ref url="/pages/wWH1dKG7tGb3PxTPMOnK" %}
[Relations & Leaderboards](/products/ultimateguilds/relations)
{% endcontent-ref %}

{% content-ref url="/pages/Q6Sr22lr7QVsBv1nogdq" %}
[Command & Permissions](/products/ultimateguilds/command-and-permissions)
{% endcontent-ref %}

{% content-ref url="/pages/ayNMX0JAj8H1Jarmbq1F" %}
[Placeholders](/products/ultimateguilds/placeholders)
{% endcontent-ref %}

{% content-ref url="/pages/WeISMZuFxnOAgBNJt614" %}
[For Developers](/products/ultimateguilds/for-developers)
{% endcontent-ref %}


# Features

## GUI

Everything is controllable via GUI for more pleasant management. From a single menu members can browse the roster, review pending invites and bans, read the guild log, and manage other players.

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-728693394796e1ca94de98e4949bf7fba4be0cb9%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Ranks & Permissions

Ranks are defined in `ranks.yml`: run three of them or eight, name them what you like, and decide for yourself whether moderators may ban. Each rank carries a weight, which is its authority, and a set of permissions. Ranks are changed with `/guild promote` and `/guild demote`, and ownership is handed over with `/guild admin`.

The file ships with the five ranks the plugin had before 3.0 — **Guild Master**, **Co-Leader**, **Moderator**, **Member** and **Recruit** — carrying exactly the permissions they had then. See [ranks.md](/products/ultimateguilds/ranks).

## Member Status

There are messages to indicate who joins or leaves the server and is part of the guild.

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-18bd4c12b5839e1439b5787fba34014a26781525%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Guild Chat

It is possible to write in the Guild chat. All members will see it, even if they have it turned off. Toggle it with `/guild chat`, or send a one-off message using the `gc`, `gchat` or `guildchat` aliases.

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-cc3f9d0b9b9ab787fb0630008e1c3d8330bb938b%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Progression

Guilds level up on an experience curve you configure, and each level can be worth something: extra member slots, a higher experience multiplier, or console commands run once on arrival. A single grant of experience crosses as many thresholds as it reaches. Staff can still set the numbers directly with `/guild xp` and `/guild level`. See [progression.md](/products/ultimateguilds/progression).

## Relations & Rivalries

Guilds can ally with each other — which needs both sides to agree — or declare rivals, which does not. Optionally, allies cannot damage each other and kills between rivals are counted. See [relations.md](/products/ultimateguilds/relations).

## Leaderboards

Guilds are ranked by experience, level, members or rival kills, readable with `/guild top` and through `%guilds_top_*%` placeholders. Boards are rebuilt on a timer and read from memory, so a scoreboard asking every tick never reaches the database.

## Tag & Color

Guilds can set a short **tag** and pick a **hex color** for it via `/guild tag` and `/guild color`. When LuckPerms is installed the tag is applied to the player's chat/nametag suffix.

## Logs

Every action within the guild (create, invite, accept, kick, ban, unban, promote, demote, rename, tag change, ownership transfer, leave) is saved and can be viewed via `/guild log` or the GUI.

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-875e2022602ba6b5ba246495c6318a89b7128f8c%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Guild Info

It is possible to see some guild info and the list of members based on rank via commands.

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-f15a282a32b6507ad32bb7c31c2da51a1222f4b9%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

<figure><img src="https://380370104-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEOWHARTcYfCelyEDnKoW%2Fuploads%2Fgit-blob-19707070081fd9919dd9f365fff9ab942b81ad7d%2Fimage.png?alt=media" alt=""><figcaption></figcaption></figure>

## Bans & Invites

Players can be invited to a guild, and troublesome players can be banned so they can no longer be invited or join. Both invites and bans are tracked and manageable from the GUI.

## Multi-Server Support

With `enable-proxy-support` and Redis enabled, guild data is shared across every server on the network, so members see the same guilds, chat and updates regardless of which server they are on.

## For Other Plugins

Ten events cover the guild lifecycle — creation, disbanding, joins, leaves, rank changes, renames, tag changes, chat, experience gains and level-ups — with the chat and experience events cancellable. See [for-developers.md](/products/ultimateguilds/for-developers).

## Integrations

* **PlaceholderAPI** — exposes `%guilds_*%` placeholders (see [placeholders.md](/products/ultimateguilds/placeholders)).
* **LuckPerms** — applies the guild tag as a player suffix and can gate the maximum member count per permission group.


# Installation

{% hint style="info" %}
UltimateGuilds requires **Java 21** and a server running **Minecraft 1.20+** (Paper is recommended).
{% endhint %}

* Drop the `UltimateGuilds-Bukkit` jar into the server `plugins` folder and start the server once to generate the configuration.
* By default the plugin stores data in an embedded **H2** database, so it works out of the box with no extra setup.
* To use **MariaDB**, set `database.type` to `MariaDB` and fill in the `database.mariadb` credentials in `config.yml`.
* For **multi-server / proxy networks**, set `enable-proxy-support` to `true` and configure the `database.redis` connection. Redis is only used to synchronise guilds across servers — a single server does not need it.

{% hint style="warning" %}
`PlaceholderAPI` and `LuckPerms` are optional soft dependencies. Install them if you want `%guilds_*%` placeholders or automatic guild-tag suffixes.
{% endhint %}


# Upgrading to 3.0

What changes when you move from 2.x to 3.0, and what to do before you do.

## Before you start

{% hint style="danger" %}
Take a backup of your database. 3.0 migrates the schema on first start, and the migration cannot be undone.
{% endhint %}

If you run several servers on one database, **upgrade them together**. The message format between servers changed, and a 2.x node cannot read what a 3.0 node sends.

## What happens on first start

The schema is brought up to date automatically and each step is recorded, so restarting is free and a failed step leaves the database on the last complete version rather than half-way through.

The step that matters is ranks. A member's rank used to be stored as a number — the position of a fixed five-value list — and is now stored as the key of a rank defined in `ranks.yml`. Existing members are mapped across on the spot:

<table><thead><tr><th width="180">Stored before</th><th>Becomes</th></tr></thead><tbody><tr><td>0</td><td>master</td></tr><tr><td>1</td><td>co-leader</td></tr><tr><td>2</td><td>moderator</td></tr><tr><td>3</td><td>member</td></tr><tr><td>4</td><td>recruit</td></tr></tbody></table>

The old column is kept and still written for one release, so a rollback to 2.x reads its members back. Members on a rank you added after upgrading come back as recruits there, which is the closest thing 2.x can express.

`ranks.yml` and `levels.yml` are written with defaults that reproduce 2.x behaviour exactly, so nothing changes for your players until you edit them.

## What changed for players

* **Banning now removes the member.** In 2.x `/guild ban` recorded the ban but left the player in the guild on the server the command was run on, so on a single server it only blocked future invites.
* **Guilds level up.** Experience used to sit there; it now buys levels on a curve, and levels can be worth something. See [progression.md](/products/ultimateguilds/progression).
* **Invites expire** after five minutes by default. Set `invites.expire-after-seconds: 0` for the old behaviour.
* **Guild tag colours survive a restart.** They were saved but never read back, so every guild came out of a restart white.

## What changed for developers

`GuildManager` still exists and still works; it is deprecated and stays for the whole 3.x line. What it returns changed in one place: `GuildMember#rank()` is now a `Rank` rather than the `GuildRank` enum, because a rank is no longer one of five fixed things.

`GuildRank` is still there, deprecated, and still names the five default rank keys.

See [for-developers.md](/products/ultimateguilds/for-developers) for the services and events that replace the manager.


# Configuration

## Configuration File

```yaml
database:
  # H2 or MariaDB
  type: "H2"
  mariadb:
    host: "localhost"
    port: 3306
    username: "root"
    password: "password"
    database: "ultimateguilds"

  # Redis is only used for multiserver support
  redis:
    host: "localhost"
    port: 6379
    username: ""
    password: "password"
    database: 0
    clientName: "UltimateGuilds"
    minimumPoolSize: 5
    maximumPoolSize: 10

# Enable multiserver support with Redis. This allows guilds to be shared across multiple servers.
# If you run only one server, do not enable this
enable-proxy-support: false

date-format: "HH:mm:ss dd/MM/yyyy"

cooldown:
  disband-confirm-time: 15

limits:
  name:
    max-length: 10
    # Regex supported
    allowed-characters:
      - "[a-zA-Z0-9_]+"
  tag:
    max-length: 10
    format: "<yellow>[<tag>]"
    # Regex supported
    allowed-characters:
      - "[a-zA-Z0-9_]+"
  description:
    max-length: 128

# Default is at the creation of the guild
# The other is when a player executes /guild maxmembers
# permission: guild.maxmembers.<group>
#             guild.maxmembers.vip
#             guild.maxmembers.admin
max-members:
  default: 10
  vip: 50
  admin: 100

luckperms:
  use-prefix-in-messages: true

prefix:
  # Set empty to disable -> ""
  set-tag: "lp user %uuid% meta setsuffix 1 \"%tag%\""
  # Set empty to disable -> ""
  remove-tag: "lp user %uuid% meta removesuffix 1"

gui:
  common:
    fill:
      material: BLACK_STAINED_GLASS_PANE
      displayName: "gui.common.fill.name"
      slot: "0-53"
  no-guild:
    info:
      material: PAPER
      displayName: "gui.no_guild.info.name"
      lore: "gui.no_guild.info.lore"
      row: 2
      column: 4
  guild:
    info:
      material: BOOK
      displayName: "gui.guild.info.name"
      lore: "gui.guild.info.lore"
      row: 0
      column: 4
  other:
    filter:
      material: HOPPER
      displayName: "gui.other.filter.name"
      lore: "gui.other.filter.lore"
      row: 0
      column: 1
    log:
      material: PAPER
      displayName: "gui.other.log.name"
      lore: "gui.other.log.lore"
    invite:
      material: PAPER
      displayName: "gui.other.invite.name"
      lore: "gui.other.invite.lore"
    ban:
      material: BARRIER
      displayName: "gui.other.ban.name"
      lore: "gui.other.ban.lore"
    member:
      material: PLAYER_HEAD
      displayName: "gui.other.member.name"
      lore: "gui.other.member.lore"
    empty-member-slot:
      material: GRAY_STAINED_GLASS_PANE
      displayName: "gui.other.empty_member_slot.name"
    back:
      material: ARROW
      displayName: "gui.other.back.name"
    next-page:
      material: ARROW
      displayName: "gui.other.next_page.name"
    previous-page:
      material: ARROW
      displayName: "gui.other.previous_page.name"

confirm-gui:
  confirm:
    material: LIME_STAINED_GLASS_PANE
    displayName: "confirm-gui.confirm.name"
    row: 0
    column: 3
  cancel:
    material: RED_STAINED_GLASS_PANE
    displayName: "confirm-gui.cancel.name"
    row: 0
    column: 5
```

## Other files

Alongside `config.yml`, UltimateGuilds writes two more files on first start. Both are merged with the bundled defaults on every start, so keys added by an upgrade appear without you having to reset the file.

<table><thead><tr><th width="180">File</th><th>Holds</th></tr></thead><tbody><tr><td>ranks.yml</td><td>The ranks a guild can have and what each may do — see <a href="/products/ultimateguilds/ranks">ranks.md</a></td></tr><tr><td>levels.yml</td><td>The experience curve and what each level is worth — see <a href="/products/ultimateguilds/progression">progression.md</a></td></tr></tbody></table>

## Invites

```yaml
invites:
  expire-after-seconds: 300
```

How long a pending invite stands before it lapses. Set it to `0` to keep invites until the server restarts, which is what versions before 3.0 did.


# Ranks & Permissions

Ranks, and what each of them is allowed to do inside a guild.

Ranks are defined in `ranks.yml`. A server can run three of them or eight, name them what it likes, and decide for itself whether moderators may ban.

The file ships with the five ranks UltimateGuilds had before 3.0, carrying exactly the permissions they had then, so an upgraded server behaves identically until you edit it.

## Defining a rank

```yaml
ranks:
  moderator:
    display-name: "ranks.moderator.display_name"
    weight: 20
    permissions:
      - INVITE
      - KICK
```

<table><thead><tr><th width="180">Key</th><th>Meaning</th></tr></thead><tbody><tr><td>The section name</td><td>The rank's key. It is what gets stored against each member, so renaming a section moves nobody: keep the key and change the display name instead.</td></tr><tr><td>display-name</td><td>A translation key from your language file. Players see what it resolves to.</td></tr><tr><td>weight</td><td>Authority. Higher outranks lower, promotion moves one step up the weights, and the highest weight runs the guild.</td></tr><tr><td>permissions</td><td>What a member holding this rank may do.</td></tr></tbody></table>

{% hint style="info" %}
The highest rank is the guild master. Promotion never reaches it — ownership is handed over with `/guild admin` — so a guild always has exactly one.
{% endhint %}

## Permissions

<table><thead><tr><th width="240">Permission</th><th>Allows</th></tr></thead><tbody><tr><td>INVITE</td><td>Invite players to the guild</td></tr><tr><td>KICK</td><td>Remove a member who does not outrank you</td></tr><tr><td>BAN / UNBAN</td><td>Ban a player from the guild, and lift a ban</td></tr><tr><td>PROMOTE / DEMOTE</td><td>Move a member up or down the ranks</td></tr><tr><td>TRANSFER_OWNERSHIP</td><td>Hand the guild over to another member</td></tr><tr><td>RENAME</td><td>Rename the guild</td></tr><tr><td>SET_TAG / SET_COLOR</td><td>Change the guild tag and its colour</td></tr><tr><td>SET_DESCRIPTION</td><td>Change the guild description</td></tr><tr><td>SET_MAX_MEMBERS</td><td>Raise the member limit, within what the player's server permissions allow</td></tr><tr><td>DISBAND</td><td>Delete the guild</td></tr><tr><td>MANAGE_RANKS</td><td>Create, delete and edit the guild's ranks</td></tr><tr><td>MANAGE_RELATIONS</td><td>Offer, declare and drop standings with other guilds</td></tr></tbody></table>

A permission the file names that does not exist is skipped with a warning, and the rank keeps the ones that do.

## Adding a rank

Add a section with a weight that places it where you want, and give it a `guilds.list.separator.<key>` line in your language file so `/guild list` has a heading for it. A key containing `-` becomes `_` in that message key: `co-leader` reads `guilds.list.separator.co_leader`.

{% hint style="warning" %}
Removing a rank while members still hold it stops those guilds from loading, with a message naming the rank. Move the members off it first, or keep the key.
{% endhint %}


# Progression

The experience curve, level-ups and what each level is worth.

Before 3.0 a guild carried an experience number and a level number that never spoke to each other: nothing levelled a guild up, and the level only moved when an admin set it by hand. Both now come from one place, configured in `levels.yml`.

Experience is cumulative. A guild's level is whatever its total experience buys, so setting a level with `/guild level` also moves its experience to that level's threshold, and the two never disagree.

## The curve

```yaml
curve:
  max-level: 50
  base: 100
  growth: 1.5
  overrides: { }
```

Reaching level 2 costs `base`. Each step after that costs `growth` times the one before, so with the defaults level 2 → 3 costs 150 and level 3 → 4 costs 225. `overrides` replaces the cost of a single step, keyed by the level being reached:

```yaml
  overrides:
    2: 50
    3: 150
```

## What a level is worth

```yaml
levels:
  10:
    extra-members: 5
    multiplier: 1.1
    commands:
      - "broadcast <guild> reached level <level>"
```

<table><thead><tr><th width="200">Key</th><th>Effect</th></tr></thead><tbody><tr><td>extra-members</td><td>Member slots this level adds. They add up across levels, and never lower a limit the guild already has from <code>/guild maxmembers</code>.</td></tr><tr><td>multiplier</td><td>The experience multiplier the guild carries from here on. Taken from the highest level reached that names one.</td></tr><tr><td>commands</td><td>Run on the console once, when the level is reached. <code>%guild%</code> and <code>%level%</code> are replaced.</td></tr></tbody></table>

Set `announce-level-ups: false` to stop the message in guild chat.

## Granting experience

`/guild xp <guild> add <amount>` adds exactly that amount and levels the guild up if it crosses a threshold — including several at once.

Another plugin grants experience through `ProgressionService`, which applies the guild's multiplier and fires a cancellable `GuildXpGainEvent` first. See [for-developers.md](/products/ultimateguilds/for-developers).


# Relations & Leaderboards

Alliances, rivalries and the guild leaderboards.

## Standings

A standing is one record for a pair of guilds, not one per side, so two guilds can never disagree about whether they are allies.

<table><thead><tr><th width="200">Command</th><th>Effect</th></tr></thead><tbody><tr><td>/guild ally &#x3C;guild></td><td>Offers an alliance. It stands as a request until the other guild offers it back, which accepts it.</td></tr><tr><td>/guild rival &#x3C;guild></td><td>Declares a rival. Takes effect at once — waiting for an enemy's permission is not how rivalries work.</td></tr><tr><td>/guild neutral &#x3C;guild></td><td>Drops whatever standing the two guilds have.</td></tr><tr><td>/guild relations</td><td>Lists who your guild stands with and against, and which offers are still pending.</td></tr></tbody></table>

All four need the `MANAGE_RELATIONS` rank permission, so you decide which rank speaks for the guild abroad. See [ranks.md](/products/ultimateguilds/ranks).

```yaml
relations:
  maximum-allies: 3
  track-kills: false
  ally-friendly-fire: true
```

<table><thead><tr><th width="220">Key</th><th>Effect</th></tr></thead><tbody><tr><td>maximum-allies</td><td>How many guilds one guild may be allied to. <code>0</code> for no limit.</td></tr><tr><td>track-kills</td><td>Count kills between rival guilds, for the kills leaderboard.</td></tr><tr><td>ally-friendly-fire</td><td>Set to <code>false</code> to stop members of allied guilds from damaging each other, projectiles included.</td></tr></tbody></table>

Both combat options are off by default, because not every server is a PvP server. Neither listener is registered at all unless one of them is turned on.

## Leaderboards

```yaml
leaderboards:
  size: 10
  refresh-seconds: 300
```

Guilds are ranked by `experience`, `level`, `members` or `kills`. Each board is one query per refresh, held in memory: `/guild top` and the placeholders read the last snapshot and never touch the database, so a scoreboard asking every tick costs nothing. A refresh that fails leaves the previous snapshot standing rather than emptying the board.

```
/guild top [experience|level|members|kills]
```

{% hint style="info" %}
`kills` ranks by kills against **rival** guilds, which is only counted when `track-kills` is on.
{% endhint %}


# Command & Permissions

The main command is `/guilds`, with the aliases `/g` and `/guild`. The `/gc`, `/gchat` and `/guildchat` aliases are shortcuts that go straight to guild chat.

{% hint style="info" %}
The `xp` and `level` sub-commands can be run from the console and target any guild by id or name: `/guild xp <guildId|name> <add|remove|set> <amount>`. Every other command must be run by a player and (unless noted) requires the sender to be in a guild.
{% endhint %}

<table><thead><tr><th width="220">Command</th><th width="180">Parameters</th><th width="230">Permission</th><th>Capabilities</th></tr></thead><tbody><tr><td>/guild create</td><td>&#x3C;name> [tag]</td><td>guilds.command.create</td><td>Create a guild</td></tr><tr><td>/guild disband</td><td></td><td>guilds.command.disband</td><td>Disband your guild</td></tr><tr><td>/guild info</td><td></td><td>guilds.command.info</td><td>Show guild info</td></tr><tr><td>/guild list</td><td></td><td>guilds.command.list</td><td>List members by rank</td></tr><tr><td>/guild open</td><td></td><td>guilds.command.open</td><td>Open the guild GUI</td></tr><tr><td>/guild invite</td><td>&#x3C;player></td><td>guilds.command.invite</td><td>Invite a player</td></tr><tr><td>/guild accept</td><td>&#x3C;guild></td><td>guilds.command.accept</td><td>Accept an invitation</td></tr><tr><td>/guild leave</td><td></td><td>guilds.command.leave</td><td>Leave your guild</td></tr><tr><td>/guild kick</td><td>&#x3C;player></td><td>guilds.command.kick</td><td>Kick a member</td></tr><tr><td>/guild ban</td><td>&#x3C;player></td><td>guilds.command.ban</td><td>Ban a player</td></tr><tr><td>/guild unban</td><td>&#x3C;player></td><td>guilds.command.unban</td><td>Unban a player</td></tr><tr><td>/guild promote</td><td>&#x3C;player></td><td>guilds.command.promote</td><td>Promote a member</td></tr><tr><td>/guild demote</td><td>&#x3C;player></td><td>guilds.command.demote</td><td>Demote a member</td></tr><tr><td>/guild admin</td><td>&#x3C;player></td><td>guilds.command.admin</td><td>Transfer ownership</td></tr><tr><td>/guild rename</td><td>&#x3C;name></td><td>guilds.command.rename</td><td>Rename the guild</td></tr><tr><td>/guild tag</td><td>&#x3C;tag></td><td>guilds.command.tag</td><td>Set the guild tag</td></tr><tr><td>/guild color</td><td>&#x3C;color></td><td>guilds.command.color</td><td>Set the tag color</td></tr><tr><td>/guild description</td><td>&#x3C;text></td><td>guilds.command.description</td><td>Set the description</td></tr><tr><td>/guild chat</td><td>[message]</td><td>guilds.command.chat</td><td>Toggle or send guild chat</td></tr><tr><td>/guild maxmembers</td><td>&#x3C;amount></td><td>guilds.command.maxmembers</td><td>Set the max members</td></tr><tr><td>/guild log</td><td>[page]</td><td>guilds.log</td><td>View the guild log</td></tr><tr><td>/guild check</td><td>&#x3C;player></td><td>guilds.check</td><td>Check a player's guild status</td></tr><tr><td>/guild xp</td><td>&#x3C;guild> &#x3C;add|remove|set> &#x3C;amount></td><td>guilds.xp<br>guilds.xp.&#x3C;add|remove|set></td><td>Modify guild XP</td></tr><tr><td>/guild level</td><td>&#x3C;guild> &#x3C;add|remove|set> &#x3C;amount></td><td>guilds.level<br>guilds.level.&#x3C;add|remove|set></td><td>Modify guild level</td></tr><tr><td>/guild ally</td><td>&#x3C;guild></td><td>guilds.command.ally</td><td>Offer another guild an alliance</td></tr><tr><td>/guild rival</td><td>&#x3C;guild></td><td>guilds.command.rival</td><td>Declare another guild a rival</td></tr><tr><td>/guild neutral</td><td>&#x3C;guild></td><td>guilds.command.neutral</td><td>Drop a standing</td></tr><tr><td>/guild relations</td><td></td><td>guilds.command.relations</td><td>List your guild's standings</td></tr><tr><td>/guild top</td><td>[board]</td><td>guilds.command.top</td><td>Show the guild leaderboards</td></tr><tr><td>/guild reload</td><td></td><td>guilds.reload</td><td>Reload the configuration</td></tr></tbody></table>

## Server Permissions and Rank Permissions

The nodes above decide whether a player may **run** a command. What they may **do** inside their own guild is a separate question, answered by their rank: `guilds.command.ban` lets a player type `/guild ban`, and the `BAN` permission on their rank decides whether it works.

Both have to allow it. Rank permissions are configured in `ranks.yml` — see [ranks.md](/products/ultimateguilds/ranks).

## Permission Groups

For the `xp` and `level` parent commands the sender needs both the parent node (`guilds.xp` / `guilds.level`) and the node for the specific action (`guilds.xp.set`, `guilds.xp.add`, `guilds.xp.remove`, and the equivalent `guilds.level.*`).

The maximum number of members a player can set with `/guild maxmembers` is capped by the tiers defined under `max-members` in the configuration (`default`, `vip`, `admin`).


# Placeholders

UltimateGuilds registers a **PlaceholderAPI** expansion under the identifier `guilds`. Install [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) and the placeholders below resolve against the guild of the player they are parsed for.

{% hint style="info" %}
If the player is not in a guild, `%guilds_inguild%` returns `false` and most placeholders fall back to a configurable "guild not found" message. `%guilds_tag%` has its own fallback when the player has no guild or the guild has no tag.
{% endhint %}

<table><thead><tr><th width="300">Placeholder</th><th>Description</th></tr></thead><tbody><tr><td>%guilds_inguild%</td><td><code>true</code> / <code>false</code> — whether the player is in a guild</td></tr><tr><td>%guilds_name%</td><td>The guild's name</td></tr><tr><td>%guilds_tag%</td><td>The guild's tag</td></tr><tr><td>%guilds_leader%</td><td>Username of the Guild Master</td></tr><tr><td>%guilds_members%</td><td>Total number of members</td></tr><tr><td>%guilds_online%</td><td>Number of members currently online</td></tr><tr><td>%guilds_invites%</td><td>Number of pending invites</td></tr><tr><td>%guilds_description%</td><td>The guild's description</td></tr><tr><td>%guilds_level%</td><td>The guild's level</td></tr><tr><td>%guilds_xp%</td><td>The guild's current experience</td></tr><tr><td>%guilds_multiplier%</td><td>The guild's multiplier</td></tr><tr><td>%guilds_maxmembers%</td><td>The guild's maximum member capacity</td></tr><tr><td>%guilds_id%</td><td>The guild's internal id</td></tr><tr><td>%guilds_created%</td><td>Creation timestamp (raw)</td></tr><tr><td>%guilds_created_formatted%</td><td>Creation date formatted with <code>date-format</code> from the config</td></tr><tr><td>%guilds_max_level%</td><td>The highest level a guild can reach</td></tr><tr><td>%guilds_xp_needed%</td><td>Experience still needed for the next level, <code>0</code> at the cap</td></tr><tr><td>%guilds_xp_progress%</td><td>How far through the current level the guild is, 0 to 100</td></tr></tbody></table>

## Leaderboards

These are about every guild rather than the reader's, so they resolve for a player with no guild too. They read the last snapshot rather than the database — see [relations.md](/products/ultimateguilds/relations) for how often it is rebuilt — and return an empty string for a position no guild holds.

<table><thead><tr><th width="420">Placeholder</th><th>Description</th></tr></thead><tbody><tr><td>%guilds_top_&#x3C;board>_&#x3C;position>_name%</td><td>The name of the guild at that position</td></tr><tr><td>%guilds_top_&#x3C;board>_&#x3C;position>_value%</td><td>Its score on that board</td></tr></tbody></table>

`board` is `experience`, `level`, `members` or `kills`; `position` counts from 1. For example, `%guilds_top_experience_1_name%` is the name of the guild with the most experience.


# For Developers

UltimateGuilds ships an `api` module (`com.ohalee.ultimateguilds.api`) that exposes a stable, read/write interface to guild data. Add UltimateGuilds to your `plugin.yml` (`depend` or `softdepend`) so the API is loaded before your plugin.

## Obtaining the API

The entry point is `UltimateGuildsProvider`. Call it once the UltimateGuilds plugin has enabled (for example inside your own `onEnable`), not from a constructor.

{% code title="Accessing the API" %}

```java
UltimateGuilds api = UltimateGuildsProvider.get();
GuildManager guildManager = api.getGuildManager();
```

{% endcode %}

{% hint style="warning" %}
`UltimateGuildsProvider.get()` throws an `IllegalStateException` if the API is not yet loaded. This usually means UltimateGuilds isn't installed/enabled, or your plugin doesn't declare a dependency on it.
{% endhint %}

```java
public interface UltimateGuilds {

    /**
     * The manager responsible for all guild operations.
     */
    @NotNull GuildManager getGuildManager();

}
```

## GuildManager

`GuildManager` is the main service. Its methods fall into a few groups:

* **Database lookups** — blocking I/O that queries the database (e.g. `loadGuild`, `find`, `getLogs`, `hasGuild`).
* **Asynchronous modification** — non-blocking writes returning `CompletableFuture` (e.g. `createGuild`, `renameGuild`, `addMember`, `banPlayer`).
* **Server cache retrieval** — in-memory, non-blocking reads (e.g. `getLoadedGuild`, `getCached`, `getLoadedGuilds`).
* **Cosmetics & messaging** — suffix refreshing and cross-server message delivery.

{% hint style="info" %}
Methods annotated `@Blocking` (and the `loadGuild`/`find` lookups) touch the database. Never call them on the main server thread — use the cache methods there, or run the lookup asynchronously.
{% endhint %}

{% code title="GuildManager (selected methods)" %}

```java
public interface GuildManager {

    // --- Database lookups (blocking / async) ---
    CompletableFuture<Optional<Guild>> loadGuild(int guildId);
    CompletableFuture<Optional<Guild>> loadGuild(UUID memberUniqueId);
    CompletableFuture<Optional<Pair<Guild, GuildMember>>> find(UUID memberUniqueId);
    CompletableFuture<Optional<Pair<Guild, GuildMember>>> find(String memberUsername);
    CompletableFuture<Optional<Guild>> findByName(String name);

    @Blocking boolean hasGuild(UUID uniqueId);
    @Blocking boolean isNameUnique(String name);
    @Blocking boolean isTagUnique(String tag);

    List<Log> getLogs(int guildId, int page, int pageSize);
    CompletableFuture<List<Log>> getLogsAsync(int guildId, int page, int pageSize);

    // --- Asynchronous modification ---
    CompletableFuture<Guild> createGuild(String name, String tag, int maxMembers,
                                         UUID leaderUuid, String leaderUsername);
    CompletableFuture<Void> renameGuild(Guild guild, String newName);
    CompletableFuture<Void> updateTag(Guild guild, String newTag, String hexTagColor);
    CompletableFuture<Void> updateDescription(Guild guild, String description);
    CompletableFuture<Void> addMember(Guild guild, UUID uuid, String username, GuildRank rank);
    CompletableFuture<Void> removeMember(Guild guild, UUID uuid);
    CompletableFuture<Void> updateMemberRank(Guild guild, UUID uuid, GuildRank newRank);
    CompletableFuture<Void> banPlayer(Guild guild, UUID targetUuid, String targetUsername,
                                      UUID bannedByUuid, String bannedByUsername);
    CompletableFuture<Void> unbanPlayer(Guild guild, UUID targetUuid);
    CompletableFuture<Void> disbandGuild(Guild guild);
    CompletableFuture<Void> updateGuildStats(Guild guild);

    // --- Server cache retrieval ---
    Optional<Guild> getLoadedGuild(int guildId);
    Optional<Guild> getLoadedGuild(UUID memberUniqueId);
    @Nullable Guild getCached(int guildId);
    @Nullable Guild getCachedByPlayer(UUID uniqueId);
    Set<Guild> getLoadedGuilds();
    void invalidateLocalCache(int guildId);

    // --- Invites, bans & members ---
    Set<GuildInvite> getInvites(int guildId);
    boolean hasInvite(UUID targetId);
    Set<GuildMember> getOnlineMembers(int guildId);
    CompletableFuture<List<GuildBan>> getBans(int guildId);

    // --- Cosmetics & messaging ---
    void refreshSuffixes(Guild guild);
    void sendMessage(UUID uniqueId, Component message);
    void broadcastMessage(Guild guild, Component message);
    void insertLog(int guildId, Log... logs);
}
```

{% endcode %}

## Guild

A `Guild` is the mutable model returned by the manager. It exposes the guild's identity, progression and roster.

```java
public interface Guild {

    int id();
    String name();
    void name(String name);

    @Nullable String tag();
    void tag(@Nullable String tag);
    String tagColor();               // hex string, e.g. "#FFFFFF"
    void tagColor(String tagColor);

    @Nullable String description();
    void description(@Nullable String description);

    int level();      void level(int level);
    int xp();         void xp(int xp);        void addXp(int amount);
    double multiplier();  void multiplier(double multiplier);
    int maxMembers(); void maxMembers(int maxMembers);
    long createdAt();

    void addMember(GuildMember member);
    void removeMember(UUID uuid);
    void updateMemberRank(UUID uuid, GuildRank newRank);

    Collection<GuildMember> members();
    List<GuildMember> getMembersByRank(GuildRank rank);
    Optional<GuildMember> getMember(UUID uniqueId);
    Optional<GuildMember> getMember(String username);
    boolean isMember(UUID uniqueId);

    void broadcast(Component component);
}
```

## Models

`GuildMember`, `GuildRank`, `GuildBan` and `GuildInvite` are lightweight, immutable models.

```java
// A member of a guild.
public record GuildMember(UUID uniqueId, String username, GuildRank rank,
                          long joined, long lastSeen) {}

// The five ranks, ordered from highest (ordinal 0) to lowest.
public enum GuildRank {
    GUILD_MASTER, CO_LEADER, MODERATOR, MEMBER, RECRUIT;

    public static GuildRank getRank(int id);
    public GuildRank nextRank();      // the rank above (null past CO_LEADER)
    public GuildRank previousRank();  // the rank below
    public Component displayName();
}

// A ban entry.
public record GuildBan(UUID targetUuid, String targetUsername,
                       UUID bannedByUuid, String bannedByUsername, long timestamp) {}

// A pending invite.
public record GuildInvite(UUID targetUuid, String username,
                          String invitedByUsername, long createdAt) {}
```

{% hint style="info" %}
`GuildRank` is ordered so that a **lower ordinal means a higher rank** — `GUILD_MASTER` is `0` and `RECRUIT` is the last value. Keep this in mind when comparing ranks (`rank().ordinal() <= other.ordinal()` means "at least as senior").
{% endhint %}

## Events

Ten events cover the guild lifecycle. They live in `com.ohalee.ultimateguilds.api.event` and are ordinary Bukkit events, so you listen for them as you would any other.

{% hint style="warning" %}
Guild changes are written off the main thread, so these events report themselves as asynchronous when they are fired from one. Do not touch the world from a listener without scheduling back onto the server thread first.
{% endhint %}

<table><thead><tr><th width="300">Event</th><th>Fired</th></tr></thead><tbody><tr><td>GuildCreateEvent</td><td>After a guild has been created, carrying its founder</td></tr><tr><td>GuildDisbandEvent</td><td>After a guild has been deleted, with its members still readable</td></tr><tr><td>GuildMemberJoinEvent</td><td>After a player has joined</td></tr><tr><td>GuildMemberLeaveEvent</td><td>After a player has stopped being a member, with a reason: left, kicked, banned or disbanded</td></tr><tr><td>GuildRankChangeEvent</td><td>After a member's rank has changed</td></tr><tr><td>GuildRenameEvent</td><td>After a rename, carrying the previous name</td></tr><tr><td>GuildTagChangeEvent</td><td>After a tag or tag colour change</td></tr><tr><td>GuildChatEvent</td><td><strong>Cancellable.</strong> Before a player's message is broadcast; the formatted component can be replaced</td></tr><tr><td>GuildXpGainEvent</td><td><strong>Cancellable.</strong> Before experience is added; the amount can be changed</td></tr><tr><td>GuildLevelUpEvent</td><td>After a guild has levelled, once per grant however many thresholds it crossed</td></tr><tr><td>GuildRelationChangeEvent</td><td>After a standing between two guilds has changed, including back to neutral</td></tr></tbody></table>

{% code title="Doubling experience during an event" %}

```java
@EventHandler
public void onGuildXp(GuildXpGainEvent event) {
    if (!happyHour) return;
    event.setAmount(event.getAmount() * 2);
}
```

{% endcode %}

## Ranks

`GuildMember#rank()` returns a `Rank`, which knows what it may do. Ask it rather than comparing ranks yourself: a server may have renamed its ranks, added its own, or moved a permission to a different one.

{% code title="Checking what a member may do" %}

```java
if (member.can(GuildPermission.KICK)) {
    // ...
}

// Acting on another member: equal ranks may act on each other.
if (member.outranksOrEquals(target)) {
    // ...
}
```

{% endcode %}

{% hint style="info" %}
`GuildRank`, the five-value enum from 2.x, still exists and is deprecated. It can only ever describe the five default ranks, so new code should use `Rank` and `GuildPermission`.
{% endhint %}

## Granting experience

Go through the progression service rather than `Guild#addXp`, so the guild's multiplier is applied, listeners get their say, and the guild levels up if it crosses a threshold.

{% code title="Rewarding a guild" %}

```java
ProgressionService progression = plugin.getProgression();
int levelsGained = progression.grantExperience(guild, 250, "my-plugin");
```

{% endcode %}

Changes you make to a `Guild` object are noticed and written back on their own — there is no save call to remember.


