System Design — Three Sync Topologies: LuckPerms, LuckyCore, NeoCore
Paper Section III. Topology-led (peer-to-peer vs master-slave). Compares the state-propagation path only — not full feature sets. LuckyCore ran in production (~2020); NeoCore reached staging (~2022, author resigned before launch); LuckPerms is the industry-standard baseline.
1. The problem all three solve
A multi-server game network is many JVM processes. A player is on one server at a time. When state changes — a rank, a player-list entry, a mute, a chat routing decision — it must reach the servers that need it, fast, and stay durable. All three systems propagate state across the fleet; they differ in topology (who is authoritative) and transport (how bytes move).
2. LuckPerms — peer-to-peer, notify-then-read
With its Redis messenger, a change is written to shared storage (MySQL), then a lightweight reload notification is published; each server reloads the affected user from storage. No authority — any server can originate. Data travels through storage, not the message (so a lost notification causes staleness, not data loss). Every server holds storage credentials.
3. LuckyCore — peer-to-peer, push-data
Author's production system. A change is written to MySQL then published as the change data itself over Redis pub/sub; each server applies it directly. No authority; fire-and-forget (no ack, no replay). Every server holds MySQL + Redis credentials.
4. NeoCore — master-slave, gRPC streaming
Author's master-slave gRPC system (12,495 LOC, 101 Java files, 13 TODO markers, no Redis at all). An authoritative master (rpc-master) owns the database; game servers are slaves (dataloader) holding no store credentials. State propagates over a bidirectional stream (PubSubRPC(stream ClientPubSubPacket) returns (stream MasterPubSubPacket)); slaves send keepalives up. A dedicated PlayerList RPC syncs online-player sets. Services: NeoCoreService, GlobalChatService, PrivateChatService, LoggerService. An annotation API (@RPCMessage / @RPCListener) lets plugins publish/subscribe typed messages. MultiAddressNameResolverFactory provides failover across master addresses.
5. Three-way structural comparison
| Property | LuckPerms | LuckyCore | NeoCore |
|---|---|---|---|
| Topology | peer-to-peer | peer-to-peer | master-slave |
| Transport | Redis pub/sub | Redis pub/sub | gRPC bidi stream |
| What is sent | reload notification | change data | change data |
| Receiver gets data from | shared DB read | the message | the message |
| Authority / ordering point | none | none | the master |
| Delivery | fire-and-forget | fire-and-forget | acknowledged |
| Loss behavior | stale until next notify (DB backstop) | stale until re-read | reconnect + refetch |
| DB load per propagation | +1 read per receiver | ~0 | ~0 |
| Single point of failure | Redis + DB | Redis broker | master (+ failover) |
| Nodes holding store credentials | N | N | 1 |
| Maturity | production, feature-rich | production (2020) | staging only |
Stated confound: topology and transport change together (P2P systems both use Redis; the master-slave system uses gRPC). The paper compares whole architectures, not one isolated variable. Separating them (e.g. a gRPC peer-to-peer or Redis master-slave configuration) is future work.
6. Map to the evaluation (7 metrics)
- Propagation latency — notify+DB-read vs broker push vs master push.
- Staleness — how long a receiver serves stale state.
- Update loss / recovery — DB backstop vs re-read vs reconnect-refetch.
- Throughput — sustained changes/sec.
- Overhead — network AND database — fairness-critical (LuckPerms trades wire bytes for DB reads).
- Failure recovery — kill DB / broker / master.
- Attack surface (analytical) — nodes holding store credentials: N, N, 1. (Caveat: NeoCore's transport is currently plaintext/unauthenticated; the advantage is credential distribution — channel hardening is future work.)
Honesty notes. NeoCore reached staging, never production → it has been exercised end-to-end by real servers, but is not a production baseline; LuckyCore is. Compare only the propagation path — LuckPerms does far more, so "faster" is not "better."
7. Audit finding (2026-08-07): NeoCore's relationship to Atreus
NeoCore does not replace Atreus — it depends on it. Verified in code:
- rpc-master/build.gradle pulls id.luckynetwork.dev.lyrams:client-api:0.6 and client-api-standalone:0.6 (Atreus artifacts).
- RPCServer.loadAtreus() calls AtreusStandaloneApiProvider.initializeStandalone(...) and System.exit(1) on failure — a hard startup dependency. config.json names the jar: "atreus": "Atreus-Client-shaded.jar".
- GlobalChatManager / PrivateChatManager call atreusApi.getDatastoreAPI().get(uuid) to read ranks for chat prefixes and per-rank cooldowns.
- HelpCommand still prints "Atreus RPC Server" — NeoCore was forked from the Atreus codebase (hence the shared MultiAddressNameResolverFactory, PubSubHelper, and near-identical PubSubRPC message shape).
The stack: Atreus = permission/rank authority. NeoCore = core services (global chat, private chat, player list, mute, logging) with its own master-slave gRPC propagation, consuming Atreus for rank lookups.
Impact on the benchmark: minimal. The measured propagation path (PubSubRPC bidi stream + PlayerList RPC) does not touch Atreus — only chat formatting/cooldowns do. For the harness, loadAtreus() can be stubbed out. Document the stub as a deviation from the shipped configuration.
8. Build feasibility (verified, not assumed)
| Module | Result |
|---|---|
:rpc (protobuf/gRPC core) |
✅ compiles |
:rpc-master (the benchmarked master) |
✅ compiles — Atreus artifacts resolve |
:dataloader-api, :dataloader |
❌ blocked — nexus.velocitypowered.com returns HTTP 522 (dead repo), not a code fault |
Toolchain: Gradle 7.4 wrapper, sourceCompatibility = 17, JDK 17 — all fine. Fix for the dataloader modules: repoint the Velocity repo (Velocity moved to repo.papermc.io) and add the Spigot snapshot repo for spigot-api:1.12.2.
The 13 "TODO markers" are false positives — 11 are generated protobuf builder stubs (throw new UnsupportedOperationException()), 1 is a utility-class constructor guard, 1 is an intentional "this executor does not support scheduling" guard. NeoCore has zero real incomplete-work markers.
9. Runtime verification (2026-08-07) — the master boots
Result: NeoCore's master starts and serves gRPC. Captured boot log:
[INFO]: Loading dependencies...
[NovenaInjector] Loaded jarFile commonslang3 with version: 3.12.0
[NovenaInjector] Loaded jarFile mysqlconnectorjava with version: 8.0.30
[NovenaInjector] Loaded jarFile commonspool2 with version: 2.11.1
[NovenaInjector] Loaded jarFile LCHikari with version: 5.0.1
[INFO]: Loading configuration...
[WARN]: No 'atreus' jar configured, running without the Atreus API.
[INFO]: Loading managers...
[INFO]: Loading listeners...
[INFO]: Loaded 9 commands
[INFO]: Loaded NeoCore RPC Server v0.1 (b96)
[INFO]: Starting NeoCore RPC Server...
[INFO]: Listening on port 50051
[INFO]: Done (5.052s)! For help type "help" or "?"
Third dependency discovered: NovenaInjector. RPCServer.loadDependencies() downloads libraries at runtime and injects them via NovenaInjector::appendJarFile, so the master must be launched with -javaagent:NovenaInjector.jar or it dies with NoClassDefFoundError. The full runtime stack is therefore NeoCore → NovenaInjector (runtime jar injection) + Atreus (rank API) + MySQL (LiteBans).
Four fixes required to get it running (all applied, all small)
| # | Problem | Fix |
|---|---|---|
| 1 | nexus.velocitypowered.com dead (HTTP 522) |
repoint to repo.papermc.io in buildSrc/.../neocore.gradle |
| 2 | net.md-5:bungeecord-chat unresolvable (spigot repo 404s it; includeGroup filter blocked papermc) |
add includeGroup 'net.md-5' to the papermc repo |
| 3 | :dataloader:jar / :rpc-master:jar zip :rpc's jar without dependsOn (pre-existing bug) |
build :rpc:jar first, or add an explicit dependsOn |
| 4 | VERSIONINFO resource missing from the repo → NullPointerException in Utils.GET_VERSION_INFO kills main |
recreate rpc-master/src/main/resources/VERSIONINFO with VERSION: @version@ / AUTHORS: [...] / NUMBER: @number@ |
Plus the benchmark stub: loadAtreus() now skips (with a warning) when config.json has an empty "atreus", instead of System.exit(1).
Non-fatal: the LiteBans MySQL connection failure only logs and retries every 15s; it does not block startup and is unrelated to the propagation path.
Feasibility verdict: Track A is low-risk. The master builds and runs from a clean checkout in under an hour of fixes. Remaining Track A work is instrumentation (timestamps on PubSubRPC / PlayerList), not resurrection.
10. End-to-end instrumentation check (2026-08-07, valbox)
The measurement path works. Master -> tagged PubSubRPC broadcast -> client -> latency row:
[master] Benchmark logging enabled, run valbox-smoke
[master] Sent 50 message(s) to 1 client(s)
[client] received 50 message(s), 50 tagged
client,bench_id,action,sent_at,received_at,latency_ns
bench-client,1,BENCHMARK,3044612701846327,3044612746750543,44904216
bench-client,2,BENCHMARK,3044612738696549,3044612797819666,59123117
50 sent, 50 received, 50 joined on bench_id. No losses.
Method note: the receiving side was a small standalone gRPC client opening the same PubSubRPC stream a dataloader slave opens, rather than a full Bukkit server. It exercises the identical stream and the same tag/latency join. One protocol detail matters for any harness: the master registers an observer only after it receives a ClientPubSubPacket, and ObserverData.isExpired() drops it after 5 quiet seconds, so receivers must keep pinging or they silently stop receiving. The first attempt logged "Sent 50 message(s) to 0 client(s)" for exactly this reason.
The numbers from this run are NOT usable as results
| value | |
|---|---|
| min | 1.90 ms |
| p50 | 3.09 ms |
| p95 | 58.65 ms |
| p99 | 59.21 ms |
| mean | 11.57 ms |
Host load average was 13.77 on 12 cores with swap fully consumed, on a box running 20+ production containers. A p50 of 3 ms and a p95 of 59 ms for a loopback gRPC push is scheduler noise, not architecture; the p95/p50 ratio of roughly 19x is the machine, not the topology. Treat this run as proof that the plumbing works and nothing more.
Testbed requirement for the real runs: a quiet machine (load well under core count, no swap pressure, no co-tenant services). Record nproc and load average alongside every run and report them in the paper's testbed description so the numbers are defensible.