Architecture
The Shatterveil server is built around seven standing rules. They were set before the first line of server code was written, and the build enforces as many of them as it can. If a rule and some code disagree, the code is wrong.
The full, canonical text is
design/04-architecture-rules.md
in the server repository. This page is the same content written for someone
about to work on the server.
The seven rules at a glance
- Everything is a module. Every game system can be switched on or off while the server runs.
- Shared code lives in libraries. Written once, reused. Modules never copy each other's code.
- Everything is documented. Javadoc on every public member; every command documents itself, including who may use it.
- Security first. Nothing a client sends is trusted. Client-side networking is only allowed in modules that aren't security-critical.
- Built for Hermes. The AI agent can help administrate, through a safe, controlled interface only.
- Built for the admin panel. The server exposes what a web panel will need without a later redesign.
- Log everything worthwhile. One structured event stream, readable on the web and in hidden staff Discord channels.
Plus one working rule for every repo: all work happens on develop; nothing
reaches main without Preck's say-so.
Rule 1: Everything is a module
A module is one game system — combat, chat, parties, quests — packaged so the server can start and stop it on its own. Each module:
- lives in its own Gradle subproject,
modules/<name>/; - has exactly one entry class implementing
ShatterveilModule; - declares a manifest: id, name, version, description, the modules it depends on, its security class, and whether it's a kernel module;
- registers everything it adds — event listeners, commands, scheduled tasks,
published services — through the
ModuleContextit is handed, never on the global Minestom managers.
Why the context matters
Minestom can undo each kind of registration: an event node can be removed from
its parent, a command unregistered, a task cancelled. The ModuleContext
records every registration it makes, and disabling a module calls
undoAll(), which reverses the whole list. A module therefore can't "forget"
to clean up, and a disabled module has no listeners, commands, tasks or
services left behind.
What the context gives a module:
| Call | What it does |
|---|---|
events() / listen(type, handler) | The module's own child EventNode, detached on disable |
registerCommand(command, doc) | Registers a command with its documentation (use the commands builder rather than calling this directly) |
scheduleTask(...) / submitTask(...) | Scheduled tasks, cancelled on disable |
publish(type, service) | Publishes a service in the ServiceRegistry, withdrawn on disable |
services() | Finds services other modules published (the only sanctioned way to talk to them) |
log() | A ModuleLog stamped with the module's id, for audit/game/system events |
logger() | An SLF4J logger for ordinary console output |
onlinePlayers() / findPlayer(name) | Read-only access to the player list |
Lifecycle
LOADED --enable()--> ENABLED --disable()--> DISABLED --enable()--> ENABLED ...
|
(error in enable) --> FAILED (logged, alerted, stays off)
onEnable(ctx): the module sets itself up through its context.onDisable(ctx): optional. The context undoes all registrations regardless; the module uses this hook to save state it owns and, where it matters, to tell affected players in plain words ("Trading is temporarily unavailable").- Dependencies: a module can't be enabled while something it depends on is
off. Disabling a module that others depend on requires
--cascade, which turns those off too. TheModuleManagerresolves dependency order and refuses cycles. - Kernel modules can't be disabled at runtime, because turning them off
would make the server unsafe or unmanageable. Today those are the module
manager itself (part of
server/),permissions,logging,player-dataandadmin-api. - State survives restarts: which modules are enabled is stored in
ClickHouse (
module_state), or indata/modules.jsonwhen no database is configured.
Controls
Modules can be toggled from three places, and all three are meant to go through the same admin API so the rules and the audit log are identical:
- in game:
/module list | info | enable | disable <id>(staff only); - Hermes, through the admin API;
- the future web admin panel.
:::info Phase 0 status
The admin API is read-only today, so /module talks to the ModuleManager
directly. Write actions on the API are Tier 5 work.
:::
When a module is done
A module isn't finished until all of these hold:
- Javadoc on every public class and method.
- A
README.md— and a page under Modules & libraries. - Every command documented through the
commandslibrary. - Tests that pass.
- A toggle test: enable → disable → enable → disable works and nothing is
left behind (
ModuleTestSupport.assertToggleLeavesNothingBehind).
Rule 2: Shared code lives in libraries
Shared code lives in library subprojects under libs/. The dependency
direction is strict:
- Modules depend on libraries.
- Libraries never depend on modules.
- Modules never depend on each other's code. They talk only through events
and through services published in the
ServiceRegistry.
The nine libraries that exist today are documented under
Modules & libraries › Libraries. Three more
(content, gui, display) are planned and will be built in the tier that
first needs them.
Enforced by the build. Gradle's convention plugins refuse a module → module
or library → module dependency at the project level, and an ArchUnit test in
server/src/test refuses it at the package level. Code review watches for
near-duplicate code: when two modules need the same function, it moves into a
library in the same change.
Rule 3: Everything is documented
- Javadoc on every public class and every public or protected method:
what it does, parameters, return value, exceptions, and whether it's
thread-safe. The build runs Javadoc with
-Xdoclint:all -Xwerror, so a missing or broken comment fails./gradlew build. - Every module has a
README.mdcovering what it does, its manifest and security class, settings, commands, events sent and received, and what happens to players when it's disabled. - Every command is documented where it's defined. The
commandslibrary requires a description, usage, examples and a permission; a command missing any of them throws at registration, so an undocumented command can't exist. The build renders these intodocs/commands.mdand fails when that file is stale — the Commands page here is the published copy. - Commands explain themselves in game. Wrong usage prints the same usage text as the docs.
Rule 4: Security first
The stance
- The server is authoritative. The client says what the player tried to do (clicked, moved, typed). The server decides what happened (damage, drops, trades, quest progress, money).
- Never trust client input. Every value from a client is checked for range, type, permission, rate and game state before anything uses it.
- Least privilege. Modules, staff roles and API tokens each get only the permissions they need.
Security classes
Every manifest declares one of two security classes.
| CRITICAL | STANDARD | |
|---|---|---|
| Examples | combat, items, currency, trading, loot, quests, permissions, moderation, admin API, player data | cosmetics, emotes, music, ambient particles, HUD style, companion skins |
| May it read custom client data (plugin-message channels, custom-click payloads, client-mod messages)? | No. Only vanilla actions (click, move, chat, inventory click), each validated server-side. | Yes, through the security library's guarded, rate-limited channel — and never to change anything in the CRITICAL list. |
| May it change player state other modules rely on? | Yes, through its own validated logic. | No. Only its own state. |
| Review | Every change needs a security note in the commit message. | Normal review. |
Enforced by the build. The ArchUnit test fails if a CRITICAL module uses
PlayerPluginMessageEvent, PlayerCustomClickEvent or packet listeners
directly. STANDARD modules must use GuardedChannel from the
security library, which checks size, type and
rate before handing data on.
Specific protections
- Inputs: every command argument, chat message, sign, book, anvil rename and GUI click is validated. GUI clicks are checked against the menu the server actually opened.
- Rate limits: per player, on commands, chat, clicks, trades and spell casts.
- Movement and combat: server-side reach, attack-speed, movement-speed and flight checks. Cheating is flagged and logged; automatic punishment comes later, once tuned.
- Money and items: every change goes through one transaction function in
the
datalibrary — atomic, logged, and unable to create or destroy value by accident. With ClickHouse this is an append-only ledger (see Database). This is what stops duplication exploits. (Planned: Tier 3,economymodule.) - Secrets: database passwords and API tokens live in environment variables or a secrets file, never in git.
- Dependencies: a vulnerability scanner watches library versions.
Rule 5: Built for Hermes
Hermes (the project's AI agent) should be able to help administrate, but never have more power than it needs.
- Through the admin API only. No console, database or file access to the game server, and it never runs server commands as raw text.
- Its own service account, with its own token and the
hermesrole. That role includes onlyplayerpermissions and can never manage roles — thePermissionTablerefuses to grant itshatterveil.role.manage, even by table edit. - Planned as an MCP server: a small bridge publishes the admin API to
Hermes as tools such as
server_status,list_modules,toggle_module,find_player,read_reportsandmute_player.
| Level | Examples | Rule |
|---|---|---|
| Read | server status, TPS, player count, module states, reports queue, audit log, player lookup | Allowed |
| Act, low impact | a short temporary mute, toggle a STANDARD module, broadcast a notice | Allowed, logged, and Preck is notified |
| Act, high impact | ban, toggle a CRITICAL module, change items or money, restart the server | Needs Preck's approval, given in the admin panel or chat, before it runs |
| Never | change permissions or roles, create API tokens, read private messages beyond a report's snapshot, read secrets | Not possible with the hermes token at all |
Every Hermes action lands in the audit log with who asked, why, and what changed — the same as actions by human staff.
Rule 6: Built for the admin panel
The web admin panel isn't being built yet, but the server is designed so it can be added without redesigning anything.
- One admin API is the single control surface. The in-game
/modulecommands, Hermes, the Discord bot (mcrpg-discord) and the future web panel all call it. There is never a second way to do the same thing. The Discord bot gets its own token and role, like Hermes. - Where it runs: a small HTTP service on the server machine, listening on localhost only, never exposed to the internet. The panel will reach it through the website backend, behind a login.
- Authentication: a separate scoped token per client. Tokens can be revoked. Every call carries the identity of the person or agent behind it.
- Audit log: every change made through the API is stored with who, what, when, why, and the before/after state.
- Versioned and documented: an OpenAPI specification is planned and will be published with the docs.
The current, read-only implementation is the admin-api
module.
Rule 7: Log everything worthwhile
Every worthwhile thing that happens on the server becomes a structured event, stored in one place and delivered to the web and to Discord. An event has a time, category, severity, actor, target, module, a short message and details.
Three kinds of event
| Kind | What it's for | Examples |
|---|---|---|
| Audit | Who changed what. Kept longest, never edited. | staff and Hermes actions, module toggles, role changes, bans and mutes, config changes, API token use |
| Game | Worthwhile things players did | joins and leaves, trades, large transfers, rare drops, boss deaths, quest completions, level-ups |
| System | The server's own health | startup and shutdown, errors, lag spikes, memory warnings, failed saves, module failures |
Security events (anti-cheat flags, rate-limit hits, rejected tokens, blocked chat, suspected duplication) cut across all three and are always flagged.
What counts as worthwhile
Log what someone might need later to answer "what happened?", "who did it?", "is something wrong?" or "is this player being harmed?".
- Always: every audit event, anything that moves value between players, every moderation action and report, every security flag, every error.
- With a threshold: money transfers above an amount, drops of Epic rarity or higher, lag spikes lasting more than a few seconds.
- Never as individual events: every step, hit or ordinary chat line — those go into metrics instead.
- Chat is kept briefly so a
/reportcan attach recent conversation as evidence, never streamed to Discord, and deleted after a short retention period.
Where it can be read
- Database (source of truth): every event is stored in ClickHouse
(
log_events). - Web: the admin panel's log viewer, once it exists. Until then the admin API serves the same data and Hermes can read it.
- Discord: hidden staff channels fed by webhooks —
#log-audit,#log-moderation,#log-security,#log-economy,#log-server, and#log-alertsfor urgent events with a staff mention. The Discord bot will later add interactive alerts on top.
Rules for the logs themselves
- Fail safe. Logging never slows down or crashes the game. Events go into a bounded queue and are written on a background thread; if the queue is full, the event is dropped and counted. If Discord is down, events wait and retry; they are always in the database first.
- No secrets and no private data. Passwords, tokens, IP addresses and
emails are never logged anywhere; the
Redactorstrips them before storage. Discord gets player names and a short summary only. - Tamper-evident. Audit events are chained with a SHA-256 hash, so an
edited or deleted row shows up in
HashChain.verify. - Never silently lost. With no database, audit events spill to
<data>/log/audit-spill.jsonland are replayed when it comes back. - Retention: chat snapshots briefly; game and system events for months; audit events longer. Exact periods are decided in the Phase 4 safety and legal review, because the players are young.
- Every module logs through the library (
ctx.log()). No module prints its own format or posts to Discord directly.
The implementation is the log library plus the
logging module.
Database: ClickHouse, local only
Every module that needs a database uses ClickHouse. There is one database and it runs on the same machine as the server, so data never leaves the host. Setup is on Getting started › Local ClickHouse.
Access rules
- Only the
datalibrary (and theloglibrary, for its event store) talks to ClickHouse. Modules never open their own connections. - Each module owns its tables and ships its schema migrations through the
datalibrary; applied migrations are recorded inschema_migrations. - Hermes, the Discord bot and the web panel never connect to ClickHouse. They read through the admin API.
- Queries are always parameterised; client input is never spliced into SQL.
Designing for ClickHouse
ClickHouse is built for fast appends and analytics, not row-by-row updates, and it has no general multi-row transactions. Data is designed around that:
- Logs and events: append-only
MergeTreetables, partitioned by month, with TTLs for retention. - Mutable records (player settings, module state, characters):
ReplacingMergeTreewith a version column. A change inserts a new row; reads take the latest. The server keeps hot player data in memory and writes asynchronously. - Money and items (planned, Tier 3): an append-only ledger. Every change is one inserted entry — transaction id, player, item or currency, signed amount, reason, and a hash chaining it to the previous entry. Balances are the sum of the ledger. The transaction function checks the change against the in-memory balance held by the single game-server process, then writes all entries of a transaction in one insert, which ClickHouse applies atomically. Inserts carry a deduplication token, so a retry can't apply a transaction twice. Nothing is ever updated or deleted; corrections are reversing entries.
:::warning Single writer The ledger design relies on one game server writing it. Before running several game servers at once, this has to be revisited (for example by routing all value changes through one ledger service). :::