| name | minecraft-plugin-repository-opinions |
| description | Opinionated standards for building Paper Minecraft plugin repositories: repo shape and single-source versioning, code architecture, state and config ownership, packet and presentation policy, cross-play GUI constraints, release contracts, and idempotency rules. Use when creating a new Paper plugin repo, scaffolding its Maven build or package layout, deciding where a piece of state or config belongs, adding packet-based presentation or display entities, designing a GUI or command that must work on both Java and cross-play clients, or cutting a plugin release and version bump. |
| compatibility | Paper plugin repositories built with Maven, on the Java version the targeted Paper line documents as required |
| metadata | {"author":"Leeor Nahum","version":"0.1.0"} |
Minecraft Plugin Repository Opinions
One repo, one Paper plugin, one durable contract. Every plugin is a small, generic, server-agnostic product built the same way: thin entry points around a real core, one owner per fact of state, and a release that never lies about what it contains. Apply every rule below to new repos; when auditing an existing repo, treat a mismatch as a finding, not a style preference.
Repo Shape
- One Maven Paper plugin per repository. Do not fold multiple independently-released plugins into one repo, and do not split one plugin's cohesive feature across repos.
- Language level matches the Java version the targeted Paper line requires. Check the target line's documented requirement at setup time rather than assuming a cached number; the requirement moves with Minecraft releases. Paper API is a
provided dependency, never shaded.
- Standard Maven layout (
src/main/java, src/main/resources), nothing bespoke.
- Single source of truth for the version: the Maven
<version> is the only place the version is typed. It is filtered into plugin.yml's version: via resource filtering, and the build's finalName produces a deterministic jar name from it. A hand-typed version anywhere else (plugin.yml, a constant, a README badge) is a bug, not a style choice.
<build>
<finalName>${project.artifactId}-${project.version}</finalName>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>
name: Sample
version: '${project.version}'
main: com.example.sample.SamplePlugin
api-version: '1.21'
Names above are placeholders showing the shape (<Name> plugin, <Name>Plugin main class); choose real names per project.
- Every repo carries its own
AGENTS.md. It is the durable, living contract for that plugin: architecture principles, package structure, key conventions (PDC keys, config file shapes, permission nodes), code style, and how to verify a change. Update it in the same change that changes the thing it documents.
- Every repo carries an operator-facing
README.md: what the plugin does, requirements, installation, the command table with permissions, and the config shape. It is written for a server admin installing the jar, not for a contributor reading the code.
Code Shape
- The
JavaPlugin subclass is the composition root and a small facade: it wires up managers, listeners, tasks, and commands, and exposes the plugin's core operations as named methods (spawn, remove, grant, reset, and so on). Listeners, commands, and tasks call into it; they never reimplement its logic.
- Listeners, commands, and tasks stay thin: parse the trigger (event, input, tick), call one or two core methods, format the response. Business logic does not live in a listener body.
- Package split, one job per package:
| Package | Job |
|---|
config | Parses and validates config.yml plus any folder-of-YAML definitions; exposes typed accessors |
model | Value types only (records), no behavior |
manager | In-memory state, registries, and persistence coordination |
listener | Thin Bukkit event handlers that translate events into manager or plugin calls |
task | Scheduled or repeating work; bounded and documented, never open-ended polling |
command | Thin command handlers: parse input, call core methods, respond |
util | Shared stateless helpers |
- Value types are Java records, not mutable POJOs with getters.
- Player-facing and console-facing messages use Adventure components, not legacy color-coded strings.
- Commands and permissions are declared in
plugin.yml, not registered imperatively in code, and every command wires tab completion. Permission nodes are namespaced to the plugin (<plugin>.<category>.<action>).
State Doctrine
- PersistentDataContainer (PDC) holds state attached to a Minecraft object: an entity, item, block, or chunk. A stable namespaced id tag on the object is that object's identity. Never encode identity, type, or tunable values in a model number, custom-model-data integer, or lore string; those are display, not data.
- Plugin data files hold plugin-owned state that has no natural Minecraft-object home: player records, counters, registries, cross-session progress.
- One owner per fact. Every piece of state has exactly one authoritative store. The same fact never lives in both a PDC tag and a data file, or in two different data files, with one silently trusted over the other.
Config Doctrine
config.yml holds global, singleton settings for the plugin.
- A folder of YAML files (one file per definition, or a few grouped files) holds repeatable domain definitions: item types, reward tiers, spawn definitions, whatever the plugin's domain repeats.
- One central parsing and validation layer per plugin is the only code that reads raw YAML. It fails loud with actionable diagnostics (file name, key path, what was expected), never a silent default substitution and never a bare stack trace surfaced to an operator.
- Never overwrite an admin's existing config file on startup or update. Fill in missing keys or migrate a schema only through an explicit, logged step; a version bump never silently rewrites a file an operator already customized.
- The hot-reload story is defined per plugin, not improvised at reload time: build a new validated config snapshot, then swap it in atomically. Anything already in flight against the old snapshot (an open session, a pending task) re-validates against the new one rather than trusting a stale reference.
- When a feature request can be served by adding a key to an existing YAML schema, do that before reaching for a new plugin or a new config file.
Generic and Sellable
- Plugins are server-agnostic products, not fixtures wired to one server's layout. No hard-coded world names, coordinates, hostnames, or host-specific business rules; every place-specific value is config.
- Every gate a plugin enforces is a permission node, never a rank name, group name, or other host-specific identity string. A host maps its own ranks onto the plugin's nodes; the plugin never knows what those ranks are called.
- Extension points are hooks, not forks: expose integration through Bukkit's
ServicesManager or a small exposed API class, and fire custom Bukkit events for anything another plugin might want to observe or veto.
- Every plugin ships a PlaceholderAPI expansion exposing its useful state (counts, cooldowns, ranges, statuses) as placeholders, even before any known consumer needs them.
Packet and Presentation Doctrine
- Default to native Paper APIs. Display entities (
TextDisplay, ItemDisplay, BlockDisplay) cover holograms and presentation objects, with setVisibleByDefault(false) plus per-player showEntity/hideEntity for visibility scoping. They are real, single-state entities; reach past them only when per-viewer divergence is actually required.
- Packets are used only through one shared presentation-transport dependency (PacketEvents), never ad hoc per-plugin packet code and never NMS reached into directly. Reach for it when presentation must bypass server logic entirely (client-only fake entities) or needs state that differs per viewer (rewritten item lore or appearance, a disguised identity on an otherwise-real item).
- Creative-mode inventory echo interception is mandatory for any client-side item disguise. Creative clients are item-authoritative, so a creative player touching a disguised item can materialize the fake data into server truth unless the transport intercepts and restores it. Survival is unaffected because the server stays authoritative there.
- Any packet-consuming code re-verifies against its exact Minecraft version and packet-library version on every bump to either. A green build does not mean the packet contract still holds; re-run the packet-path checks explicitly.
- Cite minecraft.wiki for protocol details. The former wiki.vg documentation was merged into it in 2024, so older habits and links pointing there resolve to dead pages; always use the minecraft.wiki protocol documentation hub.
Cross-Play UI
- Design every required interaction to work identically on Java and cross-play clients.
- Never make left-click versus right-click the deciding input for a required flow. Treat them as equivalent, or back one of them with a redundant button.
- Never require an anvil, sign, or other typed-text input inside a required GUI flow; typed-input UI is unreliable or absent on cross-play clients.
- Build required flows from GUI buttons, presets, and commands, all of which every client can drive identically. Reserve richer Java-only input for optional, clearly-labeled enhancements that degrade gracefully when absent.
Versioning and Release
- SemVer (
MAJOR.MINOR.PATCH), tagged vX.Y.Z.
- Release assets are immutable and named
<plugin>-<version>-paper-<mc>.jar. Never overwrite a published asset; a bad release gets a new version, not a silent replacement.
- Every version surface agrees: the Maven
<version>, the filtered plugin.yml version, README badges and install instructions, the git tag, and the release notes. A release with mismatched surfaces is not done.
- Minor bump: added or improved capability that stays compatible with existing config schema, commands, permissions, and protocol contract.
- Major bump: changed config schema, changed or removed commands, changed or removed permission nodes, or a changed protocol or presentation contract.
- Any version-sensitive internal (a data-components API call, packet-library code, an NMS adapter) lives behind a narrow, named adapter and is pinned to the exact server or library build it was verified against. Bumping that build is a deliberate, tested step, never an incidental side effect of an unrelated release.
Idempotency
- Any transaction-like API (grants a reward, moves currency, consumes a one-time collectible, applies a purchase) takes a caller-supplied request id and is idempotent on it: replaying the same request id produces the same outcome without double-applying the effect.
- Results are sealed, typed outcomes, never a bare boolean. A result type or enum states exactly what happened (already applied, newly applied, rejected with a reason). A boolean cannot distinguish "nothing happened because it already happened" from "nothing happened because it failed."
Verification Boundary
mvn clean package is the build gate. It must succeed cleanly before a change is considered done. It does not start a server and proves nothing about runtime behavior by itself.
- Manual, live-server test scenarios (the steps a human runs on a real server to confirm behavior) are documented in the repo, in
AGENTS.md or a dedicated testing doc, not left implicit in someone's head.
- Never auto-deploy a jar from a developer's working tree to any server. A deploy ships a release artifact, never a dev build.