Skip to content

Repository files navigation

RoN Community Server

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.

Architecture

[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.

Instance states

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-status until the proxy sends ron-reset
  • (OFFLINE) — proxy-side label for instances that stop answering RCON

Maps

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]]
  }
}
  • modes maps a mode name to an array of teams, each team an array of start-position indices.
  • defaultMode must be a key in modes.
  • Mode names must match the shared ModeCatalog: team modes (1v1, 2v2, 1v1v1, …), FFA as ffa_<n>, co-op as coop_<n>, up to 8 players. A mode whose layout doesn't match the catalog is logged and skipped.
  • author is 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.

Match lifecycle

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.

Custom lobbies

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".

  1. 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.
  2. Map — only maps supporting that exact player count are shown, each labelled with the format it will use.
  3. 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.

Scoring

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.

Ranks

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

discord.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.

Statistics

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.

Stats API

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

What a snapshot is, and why it is not an event log

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:

  • gatheredTotal is not the sum of gatheredFood + 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.
  • population counts queued production; unitsAlive does not. The gap is what the player has committed to but not yet received.
  • supplyBlocked is 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.

Commands

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

Building

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 build

Deployment

Velocity proxy

Drop 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-password

The 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"]

Paper 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 locks

The lobby has no database — leaderboard and rank queries are answered by the proxy over plugin messaging.

Forge instances

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 absolute

Create 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.

Discord bot

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.

Setup

  1. Create an application at https://discord.com/developers/applications, add a bot, copy the token. No privileged intents needed — leave them all off.
  2. Invite it with the bot and applications.commands scopes, plus permission to view channels, send messages, embed links, and manage roles (the last only for rank roles).
  3. Enable Developer Mode in Discord to copy channel and role ids.
  4. Fill in the discord block and set enabled: true.
  5. Run with dry-run: true first — 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.

Channels

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.

Appearance

Embed colours are configurable as #RRGGBB under discord.appearancebrand, 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.

Slash commands

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.

Rank roles

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 alerts

Nobody 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.

Account linking

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.

Queue alerts

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.

Privacy

Player names are public by default. Two controls:

  • Players run /link privacy in 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: false to publish match counts, ranks and totals without naming anyone.

/ron unlink hard-deletes the link row; there's no tombstone.

Troubleshooting

  • 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.

Dependencies

License

MIT — see LICENSE

About

A matchmaking and ranked play system for Reign of Nether a Minecraft RTS Mod

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Used by

Contributors

Languages