Matchmaking and ranked play for Reign of Nether, an RTS mod for Minecraft Forge 1.20.1.
Handles queueing, map rotation, scoring and multiple game servers — without modifying the RoN mod itself.
[Players] → [Velocity Proxy + ron-proxy]
├→ [Paper Lobby + ron-lobby]
├→ [Forge Instance 01 + RoN + ron-instance] ← RCON
├→ [Forge Instance 02 + RoN + ron-instance] ← RCON
└→ [ron-discord] → Discord
| Component | Type | Description |
|---|---|---|
ron-common |
Java library | SQLite database and migrations, scoring, rank ladder, network event feed |
ron-proxy |
Velocity plugin | Matchmaker — polls instances, routes players, records stats |
ron-lobby |
Paper plugin | Queue, commands, leaderboard |
ron-discord |
Java library | Discord bot, shaded into ron-proxy |
ron-instance |
Forge mod | Map swapping, victory detection, score updates, RCON commands |
Servers can live on different machines.
Lobby ←→ Proxy Plugin messages (transfer requests, match finding)
Proxy ←→ Instances RCON (status polling, map loading, map listing)
Proxy → Consumers Network event feed (matches, instance state, queue, alerts)
The proxy polls each instance for state and available maps: every 5s while an instance is active or reachable, every 30s when idle or unreachable. When the lobby requests a match, the proxy picks an instance and sends a load command over RCON.
The event feed (com.ron.common.net.NetworkEventBus) is one-way and non-blocking — a slow consumer drops events instead of stalling the poll loop.
IDLE → PREPARING → (restart) → READY → RUNNING → FINISHED → (reset) → IDLE
- IDLE — booted, RCON up, waiting for a load command
- PREPARING — load command received, flag file written, halting for restart
- READY — booted with a fresh map, waiting for players
- RUNNING — match in progress
- FINISHED — match over, results readable via
ron-statusuntil the proxy sendsron-reset - (OFFLINE) — proxy-side label for instances that stop answering RCON
Each instance has its own maps/ directory. Every subfolder is a RoN world save plus an rtsmap.json. The folder name is ignored — only rtsmap.json is read.
maps/
├── Duality/
│ ├── rtsmap.json
│ ├── level.dat
│ └── region/
├── Berlingrad/
└── 4Mountains/
{
"name": "Duality",
"author": ["Soly"],
"startPositions": [ ... ],
"defaultMode": "1v1",
"modes": {
"1v1": [[0], [1]],
"2v2": [[0, 2], [1, 3]],
"ffa_4": [[0], [1], [2], [3]]
}
}modesmaps a mode name to an array of teams, each team an array of start-position indices.defaultModemust be a key inmodes.- Mode names must match the shared
ModeCatalog: team modes (1v1,2v2,1v1v1, …), FFA asffa_<n>, co-op ascoop_<n>, up to 8 players. A mode whose layout doesn't match the catalog is logged and skipped. authoris optional and shown to players at match start.
Adding a map: build it on a RoN server, place start-position blocks (colored = auto-allied) where each team spawns, save, write rtsmap.json in the world folder, and copy the folder into an instance's maps/. It's picked up on the next boot or ron-maps poll.
Instances don't need identical map pools — put the big maps on the beefier servers.
1. Players /queue in the lobby
2. At 2+ players (configurable), a 120s fill window opens
3. Fill ends → room locks, 60s map+mode vote (/vote <number>)
4. Lobby asks the proxy for a match on the winning map/mode
5. Proxy picks a free instance, sends ron-loadmap over RCON
6. Instance writes a flag file and halts; on shutdown it swaps world/ for the
new map, then the process manager restarts the JVM
7. Instance boots on the fresh level.dat → READY
8. Proxy sees READY and transfers players from the lobby
9. Players pick start positions and factions → RUNNING
10. Victory detected (last player or team standing) → FINISHED
11. Proxy reads matchResult, writes it to SQLite, players see the victory screen
12. Proxy sends ron-reset; instance restarts back to IDLE
Fill and vote durations live in ron-lobby's config.yml.
A host picks how many players the game is for, and then a map. That order matters: the player count is the one decision that depends on nothing else, and fixing it up front is what lets the rest of the network say "waiting for 2 more" — before this, a lobby had no target size until a map was chosen, so it could only report "someone is looking for a game".
- Players — 2 to 8. Sizes the network cannot currently run are greyed out, and so is anything below the number already in the lobby: lowering it would mean throwing somebody out. A host with three people cannot pick 2.
- Map — only maps supporting that exact player count are shown, each labelled with the format it will use.
- Format is automatic — the map's own default where it declares one, otherwise a team mode in preference to FFA or co-op. It is shown on the map tile before you commit.
The chosen count is the lobby's capacity. Joins beyond it are refused and the lobby shows as Full in the browse menu; raising the count reopens it (and clears the map, since the maps that fit four players are not the maps that fit six).
Once the host presses Start, the setup is locked. The settings are read exactly once, into the match request the proxy receives, and are never looked at again — so a change made afterwards could only ever be a lie told to the host: the menu would show fog on and the match would start without it. While the lock holds, the host controls render greyed out, clicks are refused with a message, and nobody new can join a lobby whose roster has already been sent. If the start falls through — no instance available, not enough players, the transfer times out — the lock is released and the host can fix it and try again.
One gap term drives both sides — the opponent's average points minus yours — so beating someone well above you pays double, and losing to someone well below you costs nearly triple.
gap = opponentAvgPoints - myPoints # positive means they were stronger
win = clamp(25 + gap / 8, 10, 50)
loss = clamp(15 - gap / 8, 5, 40)
| gap | -200 | -100 | 0 | +100 | +200 |
|---|---|---|---|---|---|
| win | +10 | +13 | +25 | +37 | +50 |
| loss | -40 | -27 | -15 | -5 | -5 |
A loss never drops a player below zero. Draws and co-op wins count as games played but move no points and don't break streaks. Unranked and custom matches are still recorded in full — roster, winners, duration — they just don't touch the ladder.
There's one ladder for the whole network, computed by the proxy. The game renders a player's position on it and Discord turns the same positions into roles, so the two can't disagree.
A standing is always a position, never a name — #1, Top 10, Top 25%, or a plain #42 of 60. There are no tiers and the ladder isn't configurable. Rungs are checked in order, first match wins:
| slug | shown as | matches |
|---|---|---|
top-1 |
#1 |
place 1 |
top-3 |
Top 3 |
places 1–3 |
top-10 |
Top 10 |
places 1–10 |
top-25 |
Top 25 |
places 1–25 |
top-10-pct |
Top 10% |
top 10% |
top-25-pct |
Top 25% |
top 25% |
top-50-pct |
Top 50% |
top 50% |
ranked |
#42 of 60 |
everyone else |
The percentage rungs only start mattering on a large network — with 200 ranked players Top 25 already covers the top 12.5%. Below 20 ranked players nobody is ranked at all and everyone reads Unranked.
Ties never split a rung: a player's place is the number of ranked players holding at least as many points, so tied players share a rung and a tied run straddling a boundary falls to the lower side.
Policy lives in the proxy config:
ranking:
placement-games: 5 # games before a player is ranked at all
activity-window-days: 60 # inactive players leave the distribution
demote-margin: 25 # hysteresis, so boundary players don't flip role every match
recompute-minutes: 60 # idle ceiling; a ranked match also triggers a refreshdiscord.roles is keyed by the slugs above, so mapping only top-1 and top-10 is fine — an unmapped rung just means no role.
The ladder is recomputed rather than updated per match, so your standing can change without you playing. demote-margin applies to Discord roles only, to stop boundary players flipping role every match. The match-end screen shows the points change but not the new rank — promotions are announced in Discord.
The proxy records to SQLite (plugins/ron-proxy/ron.db):
| Table | Contents |
|---|---|
players |
Points, wins, losses, games, current and best streak, peak points, time online, time in matches, first seen, last played, privacy opt-out |
matches |
Instance, map, mode, ranked/private flags, alliance lock, fog, state, outcome, start and finish times, duration |
match_players |
Per-match roster with winner flags, faction, and the rating change actually applied |
player_sessions |
One row per connection — daily uniques and peak concurrency |
network_daily |
Per-day rollups: matches, ranked matches, time played, unique players, peak online, new players |
discord_links |
Discord account links |
match_player_entities |
Per unit and building type, per player: produced, lost, killed, first seen |
match_player_research |
Completed upgrades and when they finished |
match_samples |
Periodic full-state snapshots: stockpiles, cumulative gathered, population vs supply cap, army value, building and research counts, capitol and beacon status, cheat flags |
match_sample_units |
Living units by type at each sample, sparse |
match_sample_buildings |
Buildings by type at each sample, built and under construction, sparse |
match_events |
The ordered per-player event log — what a build order is read from |
api_keys |
Keys for the stats API (hashed; only the prefix is stored in the clear) |
Recorded rating changes are the values actually applied — losses floor at zero, so a player on 5 points "loses 15" but the table shows -5. Playtime is written on a heartbeat (stats.playtime-heartbeat-minutes), so a crash loses minutes rather than whole sessions. Set stats.log-events: true to mirror every network event to the proxy log.
A read-only HTTP API over everything in the tables above, served by the proxy itself. Off by
default; switch it on in the api block of config.yml.
It is embedded in the proxy rather than run as its own service for one reason: how many people are online, and what the instances are doing right now, exists only in that JVM. A separate process reading the same database file could serve history and nothing else.
Keys. Every request needs one. Create it from the console or in game:
/ronadmin apikey create website # all four read scopes
/ronadmin apikey create partner stats:read # or name them explicitly
/ronadmin apikey scopes <prefix> <scopes> # re-scope in place, no reissue
/ronadmin apikey list [--all]
/ronadmin apikey revoke <prefix>
The key is printed once. Only its SHA-256 is stored, so a lost key is revoked and reissued,
never recovered — the prefix in list is the handle revoke takes and the only part safe to
paste anywhere. Revocation takes effect within a minute, since verified keys are cached briefly.
Present it as Authorization: Bearer <key> or X-Api-Key: <key>.
Scopes are comma-separated and checked per endpoint:
| Scope | Grants |
|---|---|
stats:read |
Network totals, live status, daily rollups |
players:read |
Leaderboard and player profiles |
matches:read |
Match history and match detail |
telemetry:read |
Per-match telemetry, state snapshots, event logs, faction analytics |
* |
Everything, including players who opted out of public stats |
Naming no scopes on create grants all four read scopes, but never *. Scopes are not fixed for
the life of a key — apikey scopes changes them in place, so a consumer never has to swap the key
it already holds. Like revocation, it takes up to a minute to bite.
Endpoints, all under /api/v1:
| Endpoint | Scope | Returns |
|---|---|---|
GET /meta/ping |
any | Whether the key works, and what it may do |
GET /network/totals |
stats:read |
Registered players, matches, total hours played, active players, online now |
GET /network/status |
stats:read |
Live: who is online, every instance and its state |
GET /network/daily?from=&to= |
stats:read |
Daily rollups (defaults to the last 30 days) |
GET /leaderboard?limit=&offset= |
players:read |
The ranked ladder with bands |
GET /players/{uuidOrName} |
players:read |
Full profile, ladder position and rank |
GET /players/{uuidOrName}/matches?limit= |
players:read |
That player's recent matches |
GET /matches?limit=&publicOnly=&map=&mode=&before= |
matches:read |
Matches with rosters, filterable and fully walkable (public-only by default) |
GET /matches/{id} |
matches:read |
One match, including the rules it ran under and the mod version |
GET /matches/{id}/telemetry |
telemetry:read |
Per-player counters, entity and research rollups |
GET /matches/{id}/timeline |
telemetry:read |
Full-state snapshots: economy, supply, army composition and value, buildings by type |
GET /matches/{id}/events?player=&fromTicks=&toTicks=&limit=&after= |
telemetry:read |
The ordered event log — build orders live here |
GET /analytics/maps?sinceDays=&mode=&publicOnly=&limit= |
stats:read |
Per-map totals over all history: matches, ranked split, total and average duration, busiest mode, last played |
GET /analytics/modes?sinceDays=&publicOnly= |
stats:read |
The same per game mode |
GET /analytics/factions?sinceDays= |
telemetry:read |
Faction pick and win rates |
Every 30 seconds each player's whole position is recorded: what they hold, not what happened.
That distinction is the point. Production used to be inferred by watching for units entering the
world, which only fires on one of RoN's several spawn paths — measured across six real matches it
captured 205 of 703 units, missing everything for some factions and double-counting conversions
elsewhere. RoN's own TOTAL_UNITS_PRODUCED is no substitute: it only counts units finished from a
production queue, so raised and converted units are invisible to it too.
A census cannot miss a birth it never had to witness, and a sample that fails to run costs one row rather than corrupting a running total. Deaths remain event-driven, because nothing survives a death to be counted — and those reconcile exactly against the mod.
Three fields need explaining:
gatheredTotalis not the sum ofgatheredFood+gatheredWood+gatheredOre. RoN credits a worker carrying a mixed load to only the first resource type it checks, so the per-type counters undercount while the total does not. Use the total for income; use the parts only for the split.populationcounts queued production;unitsAlivedoes not. The gap is what the player has committed to but not yet received.supplyBlockedis derived:population >= supplyCap. Consecutive blocked samples are the most common macro mistake in the game and were invisible before.
cheated flags a sample where a cheat was active. It matters because cheats make research read as
held and supply read as maxed — a match that did not record this could not be excluded from
aggregates afterwards. Filter on it before trusting any research or supply figure.
Unit and building types are registry keys (reignofnether:zombie_unit), never display names.
Display names split a building across its upgrade tiers into unrelated strings. The vocabulary is
interned at write time and discovered at runtime, so a server on a newer RoN than the proxy was
built against records its new types correctly rather than dropping them.
null means "no such value", and is never rendered as a number. Three fields are absences
rather than measurements: tTicks and tSeconds on an event the instance could not date against
the match clock, firstAtSeconds and produced on a unit entry (nothing counts unit births any more — use /timeline for composition), and firstAtSeconds on an entity the player never produced — a row that exists
only because they killed or lost one. Buildings that were on the map before the match started
(capturable neutrals, village structures) fall into the first case: nobody in the match built them
and their age is measured from world load, not from the opening tick. Earlier builds reported all
three as 0 or -1, which put captured buildings at 0:00 in build orders and let a consumer plot
a point before the match began. Treat null as "not applicable" and leave the field out of any
chart or ordering.
Aggregates are computed in SQL over the whole database, never over a page of it. Counting maps
client-side from /matches can only ever describe the window that was fetched, and reports "most
played map" when it means "most played map in the last hundred games" — /analytics/maps is there
so nobody has to.
Two endpoints page. /matches returns nextBefore whenever a full page comes back; feed it in as
before to walk the entire history. /matches/{id}/events returns nextCursor; feed it in as
after — a long match is tens of thousands of rows. Both cursors carry a tiebreak alongside the
timestamp, because timestamps collide and a bare one silently skips every row tied with the last of
a page. In both cases the cursor stops being returned once you reach the end. Bots are excluded from every
cross-match aggregate: a weekend of practice games against the AI should not decide what the
network's most popular faction looks like.
Errors are always JSON, {"error":{"code":"...","message":"..."}}, with unauthorized,
forbidden_scope, not_found, player_opted_out, rate_limited (plus Retry-After),
bad_request and database_unavailable. Players who opted out are excluded from the leaderboard,
return player_opted_out on lookup, and appear as Hidden player with a null uuid in match
rosters — their seat stays, so the match size is still reported correctly.
Put it behind TLS. The listener speaks plain HTTP and an API key is a bearer token: anyone who
can read the traffic can reuse it. bindAddress defaults to loopback for that reason. Terminate
TLS in the reverse proxy that already fronts the website:
location /api/ {
proxy_pass http://127.0.0.1:25585;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}Consumers should call it server-to-server and keep the key off the page. allowedOrigins exists
for the cases that cannot, and is empty by default.
Lobby
| Command | Description |
|---|---|
/queue |
Join the matchmaking queue |
/leave |
Leave the queue |
/vote <number> |
Vote for a map+mode option during the lock phase |
/matches |
List running matches and available servers |
/spectate <instance> |
Watch a running match |
/leaderboard |
Top 10 players |
/rank |
Your stats |
/ronstatus |
Full server status — OP, perm ron.status |
Proxy — registered proxy-side, so they work from the lobby and inside a match.
| Command | Description |
|---|---|
/rejoin |
Return to a match you dropped out of |
/link |
Get a code to link your Discord account |
/link privacy [on|off] |
Hide or show your stats in public lists and announcements |
/ronadmin apikey <create|scopes|list|revoke> |
Manage stats API keys — perm ron.admin |
RCON (instance)
| Command | Description |
|---|---|
ron-maps |
Available maps + modes, as JSON |
ron-status |
State, current map/mode, players, game time, and matchResult when FINISHED, as JSON |
ron-loadmap <map> |
Validate map, write flag file, halt for the map swap |
ron-setmode <mode> |
Set the mode for the current map (must exist in rtsmap.json) |
ron-setprivate <true|false> |
Mark the next match private/unranked |
ron-playerscores <json> |
Push pre-match scores from the proxy |
ron-reset |
Return the instance to IDLE after a match |
Needs Java 17+ and a RoN mod jar (1.4.0 or newer) in ron-instance/libs/ — exactly one, the build globs *.jar.
# lobby + proxy
./gradlew build
# instance
mkdir -p ron-instance/libs/common
cp ron-common/build/libs/ron-common-1.0.0.jar ron-instance/libs/common/
cd ron-instance && ./gradlew buildDrop ron-proxy-1.0.0.jar in plugins/ and install Ambassador.
On first start it writes a fully commented plugins/ron-proxy/config.yml. The only block you have to edit is instances:
instances:
instance01:
rconHost: 192.168.1.10
rconPort: 25575
rconPassword: your-password
instance02:
rconHost: 192.168.1.11
rconPort: 25575
rconPassword: your-passwordThe rest have working defaults:
| Block | Controls |
|---|---|
database |
SQLite path, relative to the plugin data directory |
ranked |
Network-wide ranked switch; false makes every match unranked |
rankSync |
Optional HTTP rank sync with trusted peer proxies (off by default) |
gameModes |
Mode allow-list; omit the block to allow everything |
network |
Display name, lobby server name, spectator slots, timezone |
stats |
Playtime heartbeat interval, event logging |
ranking |
Ladder policy (see Ranks) |
timings |
Poll intervals, RCON timeouts, transfer stagger, reboot cooldown |
discord |
The Discord bot — see below. Disabled by default |
Config is read once at startup; changes need a restart. Settings added by a later build are merged into your config.yml on the next start, with their comments and in place — your values, comments and instances block are left alone, and the previous file is kept as config.yml.bak. The database migrates itself on startup, and uses WAL journalling — back up ron.db, ron.db-wal and ron.db-shm together, or use VACUUM INTO 'backup.db'.
# velocity.toml
[servers]
lobby = "127.0.0.1:25566"
instance01 = "192.168.1.10:25565"
instance02 = "192.168.1.11:25565"
try = ["lobby"]Drop ron-lobby-1.0.0.jar in plugins/.
# plugins/RonLobby/config.yml
show-welcome-message: true
queue:
fill-seconds: 120 # queue stays open this long after minPlayers is reached
vote-seconds: 60 # combined map+mode vote after the room locksThe lobby has no database — leaderboard and rank queries are answered by the proxy over plugin messaging.
Drop ron-instance-1.0.0.jar, the RoN mod and Proxy-Compatible-Forge in mods/, then enable RCON:
# server.properties
enable-rcon=true
rcon.port=25575
rcon.password=your-password# serverconfig/ron-instance.toml
[maps]
pool = "maps" # maps pool directory, relative to the server root or absoluteCreate maps/ and add map folders. The instance has no database — the proxy reads results from ron-status once the instance hits FINISHED and writes them to its own SQLite.
The bot ships inside ron-proxy — no second process to run. It can't affect matchmaking: it never blocks the RCON poll loop, and if it fails to start the proxy carries on without it.
- Create an application at https://discord.com/developers/applications, add a bot, copy the token. No privileged intents needed — leave them all off.
- Invite it with the
botandapplications.commandsscopes, plus permission to view channels, send messages, embed links, and manage roles (the last only for rank roles). - Enable Developer Mode in Discord to copy channel and role ids.
- Fill in the
discordblock and setenabled: true. - Run with
dry-run: truefirst — everything the bot would post goes to the proxy log instead. Turn it off once the output looks right.
Prefer the RON_DISCORD_TOKEN environment variable over discord.token; it overrides the config value and keeps the secret out of config files, backups and bug reports.
| Channel | Contents |
|---|---|
channels.stats |
One embed, edited in place: players online, server states, live matches, top players, all-time totals |
channels.queue |
One embed, edited in place: the public queue, players still needed, open custom lobbies |
channels.history |
One embed per finished match: map, mode, duration, winners and losers with rating changes |
channels.announcements |
Rank-ups, win streaks, daily and weekly recaps |
channels.ops |
Operator alerts: instance offline, failed resets, abandoned matches |
Leave a channel id empty to disable that feature. The two panels edit a single message rather than posting new ones; the message id is persisted, so a restart edits the existing panel instead of leaving duplicates, and a panel deleted by hand is detected and reposted.
Embed colours are configurable as #RRGGBB under discord.appearance — brand, win,
draw, abandoned, warn, info. Discord renders these as the stripe down the left edge of
each message, so it is the main lever for making the bot look like part of your server. A blank
or malformed value keeps the default.
| Command | Notes |
|---|---|
/ron online |
Live network status |
/ron queue |
Queue and open lobbies |
/ron stats [player] |
Profile — defaults to your linked account |
/ron leaderboard [size] |
Top ranked players |
/ron match <id> |
One match in detail |
/ron link <code> |
Redeem the code from /link in game |
/ron unlink |
Delete your account link |
/ron whois <user> |
Which Minecraft account a member is linked to — staff-only by default |
/ron alerts |
Toggle game alerts for yourself |
/ronadmin refresh |
Refresh both panels now |
/ronadmin repost <stats|queue> |
Repost a panel as a new message |
/ronadmin resync-roles |
Recompute the ladder and resync roles |
Commands register to the single guild in discord.guild-id, so they appear immediately instead of taking up to an hour. /ronadmin needs Manage Server or a role in discord.admin.role-ids.
discord.roles maps ladder rungs to Discord roles using the slugs from Ranks.
discord:
roles:
rank-roles-enabled: true
top-1: "1234..." # any subset of the rungs; blank means no role
top-10: "1234..."
linked: "1234..." # anyone with a linked account
queue-ping: "1234..." # self-assignable, pinged by queue alertsNobody holds a rank role while the network is below the population floor or while a player still owes placement games.
Every rank role must sit below the bot's own role in Server Settings → Roles, or Discord refuses to assign it.
Players run /link in game for a six-character code, then /ron link <code> in Discord. Starting in game means no name lookup is involved, so nobody can claim someone else's account. Codes are single-use and expire after discord.linking.code-ttl-minutes. /link is a proxy command, so it works from inside a match too.
When players are waiting but can't start yet, the bot pings a self-assignable role. Enable discord.queue-alerts and set discord.roles.queue-ping.
Members opt themselves in — the bot never assigns the role on its own. They click the button on the opt-in message the bot posts in the alert channel, or run /ron alerts. /ronadmin alerts-message reposts that message if it gets deleted.
The ping and the opt-in message can live in different channels (queue-alerts.channel and queue-alerts.opt-in-channel). Wording is configurable via ping-format, with {waiting}, {needed} and {min} — e.g. "{role} New game starting — waiting for {needed} more player(s)!". The cooldown survives restarts, and mentions are allow-listed to that one role.
Player names are public by default. Two controls:
- Players run
/link privacyin game to hide themselves from stat lookups, leaderboards and announcements. They still count toward network totals — they're just never named. - Operators can set
discord.linking.show-names: falseto publish match counts, ranks and totals without naming anyone.
/ron unlink hard-deletes the link row; there's no tombstone.
- Bot never connects. A bad token logs one line (Discord close code 4004) and the bot stays down. The proxy keeps running.
- JDA log spam. JDA is relocated into the jar, so filter on the new prefix:
<Logger name="com.ron.libs.jda" level="WARN"/> - Panel refresh intervals below 15s are clamped to 15 — Discord only allows five edits per five seconds per channel.
- Reign of Nether by SoLegendary (GPL-3.0)
- Ambassador — Velocity plugin for Forge compatibility
- Proxy-Compatible-Forge — Forge mod for Velocity support
- Velocity — Minecraft proxy
- Paper — Minecraft server
- JDA — Discord API library (Apache-2.0), shaded into
ron-proxy
MIT — see LICENSE