think
16px
820px

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.

sequenceDiagram autonumber participant A as Origin Server participant DB as MySQL shared storage participant R as Redis messenger participant B as Other Server A->>DB: write change A->>R: publish reload notification R-->>B: deliver notification B->>DB: reload affected user Note over B,DB: data comes from storage, not the message

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.

sequenceDiagram autonumber participant A as Origin Server participant DB as MySQL participant R as Redis broker participant B as Other Server A->>DB: write change A->>R: publish change DATA packet R-->>B: deliver packet B->>B: apply from packet Note over R,B: fire and forget, no ack and no replay

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.

sequenceDiagram autonumber participant A as Origin Slave participant M as NeoCore Master participant DB as MySQL participant B as Other Slave A->>M: state change over stream M->>DB: persist M-->>A: ack M->>B: push MasterPubSubPacket B->>B: apply from push Note over M,B: keepalive detects dead slave and drives reconnect

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)

  1. Propagation latency — notify+DB-read vs broker push vs master push.
  2. Staleness — how long a receiver serves stale state.
  3. Update loss / recovery — DB backstop vs re-read vs reconnect-refetch.
  4. Throughput — sustained changes/sec.
  5. Overhead — network AND database — fairness-critical (LuckPerms trades wire bytes for DB reads).
  6. Failure recovery — kill DB / broker / master.
  7. 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 repoNullPointerException 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.