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.
You are only ever in one thing at a time. Creating or joining a lobby leaves whatever you were in first — the other lobby, Quick Match, the ranked queue — rather than refusing. Being a member of two rosters at once was what produced lobbies nobody could get rid of: the one you walked away from still counted you, so it never emptied, never disbanded, and sat in the browse menu and in Discord advertising a game with nobody in it. A lobby is also dropped once its host goes offline, since only the host can start it or change its setup, and the lobby server re-sends its full state to the proxy every 30 seconds so a single lost message cannot leave the two disagreeing for long.
Everyone starts on 100 points. 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.
Two accounts trading wins is the only realistic way to farm this ladder — a pair of friends taking turns, or one person running an alt. Refusing to pair them is not an option on a small network, where the two people in the queue are frequently the only two people online: a matchmaker that won't start that game never starts a game. So the match runs and the points stop.
Three rules, none of which require deciding that anyone cheated.
A starting balance, so nobody is free to beat. A player on zero cannot lose anything, because the ladder floors there. That made a fresh account a bottomless well — it paid out the winning minimum every game and never ran dry, forever, at no cost to itself. Starting everyone on 100 means a fed account has a finite amount to give.
Beating a drained account pays nothing. When the zero floor truncates the loss, the winner's gain is capped at what was actually taken. An ordinary match, where the loser paid in full, is untouched — the ladder stays deliberately non-zero-sum, because one bad night should not erase a month. This only ever fires against someone with nothing left.
Repeat pairings decay. The same two players meeting over and over is the one signal every version of this shares, and it needs no IP, no fingerprint and no accusation:
| ranked games vs the same opponent in 24h | pays |
|---|---|
| 1–4 | 100% |
| 5–6 | 50% |
| 7+ | nothing |
Scaled on the win and the loss alike, so a genuine rivalry freezes in place relative to each other rather than bleeding points for being the only two people who play this game. The games still count, still appear in history, still move win/loss and streaks. Honest sessions are two to four games and never reach the cap; a grinder hits it within the hour. The window resets daily, so nobody is ever locked out of playing their friend.
The instance is told the resulting percentage before the match starts (ron-setpointscale), so the victory screen quotes the number the ladder is actually going to move rather than promising 25 and delivering nothing.
Entry requirements raise the cost of each throwaway account — by default three games played anywhere on the network and an account 24 hours old before the ranked queue will take it. Games are counted across all modes, so the requirement is met by turning up and playing.
There is deliberately no IP or hardware check. It flags housemates and a shared hotspot as readily as a cheat, loses to a free VPN, and turns every false positive into an argument somebody has to arbitrate. A waiting period is wrong about nobody, and against a ladder where farming already pays zero there is very little left to catch.
Run ./gradlew :ron-common:antiBoostCheck to verify all of it against a temporary database.
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 refresh
rematch: # anti-boost decay — see Boost protection
enabled: true
window-hours: 24
full-games: 4 # games vs the same opponent that pay in full
half-games: 6 # ...then half, up to here; nothing beyond
entry: # what an account must do before ranked opens
min-games: 3
min-account-hours: 24discord.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 (new accounts start at 100), 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 |
GET /maps |
stats:read |
The map registry: every map the network has seen, whether it is installed right now, and the name it gives itself |
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.
A map is identified everywhere by its folder — that is what matches.map_folder holds, what
the map= filter takes, and what every aggregate groups by. A folder is a poor label, though, so
anything carrying a map also carries mapName: the name the map gives itself in its rtsmap.json.
The names live in the maps table, keyed on folder, written whenever an instance reports its map
list over RCON. Because they are joined in by folder at read time rather than stamped onto each
match row, a map reported once is named across its entire history and not just the games played
after it was first seen. The trade is that renaming a map relabels its old matches too, which is
the intended reading — it is the same map.
mapName is omitted, not defaulted, for a folder the registry has never seen, so a consumer
can tell "no name recorded" from a name that happens to match the folder. Every consumer needs a
fallback of its own for that case; the website title-cases the folder. /maps returns the registry
in full, including maps nobody has played yet and maps no longer installed anywhere.
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-setpointscale <0-100> |
What fraction of the normal points this match pays (rematch decay) |
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