Paper / Spigot
Reports player counts and arrivals, exposes atlas() and setStatus(), delivers messages on the Bukkit scheduler, and owns orphan safety shutdown.
Java API · 1.0
The Atlas Java SDK gives every Minecraft server and proxy one view of the whole network. Read projected state from memory, make authoritative claims asynchronously, and keep running when the control plane is far away.
To drive Atlas itself from a script or a service — create groups, publish templates, start and stop servers, read analytics — see the HTTP API.
try (AtlasClient atlas = AtlasClient.connect()) {
atlas.awaitReady(Duration.ofSeconds(5));
// Local: no network call.
int online = atlas.playerCount("squidgame");
// Central: returns immediately.
atlas.reserveSlot("squidgame", playerId)
.thenAccept(slot -> connect(slot.serverId()));
}
Get started
Atlas requires Java 17 or newer. The SDK is published to the Atlas Maven repository at repo.atlasgg.com, which is public — no credentials, no account. Add the repository and the dependency to your plugin build.
You do not add the platform adapters. Atlas installs the Paper, Velocity, and BungeeCord adapters onto your servers itself, the same way it installs any managed plugin; declaring a dependency on one would bind your plugin to an internal.
An Atlas-managed process receives ATLAS_SDK_SOCKET and ATLAS_SERVER_ID from its local agent. Do not put server IDs or control-plane credentials in your plugin.
com.atlasggatlas-sdk0.1.017+<repositories>
<repository>
<id>atlas</id>
<url>https://repo.atlasgg.com</url>
</repository>
</repositories>
<dependency>
<groupId>com.atlasgg</groupId>
<artifactId>atlas-sdk</artifactId>
<version>0.1.0</version>
</dependency>
Gradle: maven { url "https://repo.atlasgg.com" }, then implementation "com.atlasgg:atlas-sdk:0.1.0".
Get started
Call AtlasClient.connect() during plugin startup. It opens the local Unix socket and identifies the current Atlas server using agent-provided environment values.
The client initially has an empty registry. If “not loaded yet” must be different from “the network is empty,” wait for the first projection with awaitReady.
A process started outside the agent will fail clearly because its socket path and server identity are absent.
private AtlasClient atlas;
public void start() throws IOException {
atlas = AtlasClient.connect();
atlas.awaitReady(Duration.ofSeconds(5));
}
public void stop() throws IOException {
if (atlas != null) atlas.close();
}
Get started
Method shape tells you what the call costs. Synchronous query methods read the SDK’s in-process projection. Methods that need authoritative agreement return CompletableFuture. Fire-and-forget mutations can throw IOException if they cannot reach the local agent.
T method()servers(), playerCount()Advisory state on hot pathsCompletableFuture<T>reserveSlot(), locate()Placement and central truthvoid … throws IOExceptionsetStatus(), send()Reports and best-effort deliveryGet started
/hubOne file, one command: send a player to a lobby. It is small enough to read in a minute and still exercises the three things every Atlas plugin has to get right.
routableServers reads this process’s own copy of the registry — no socket, no network — so an empty lobby group is answered instantly instead of after a failed round trip.transfer reserves a seat against live capacity and then asks the proxy holding the player to move them. Picking a server yourself would skip the reservation and can land someone on one that filled in the meantime.The SDK depends on protobuf-java, and so do other plugins, at other versions. Left in its own package the first plugin loaded wins and the rest fail with NoSuchMethodError at runtime — usually under load, usually blamed on the wrong plugin. Atlas relocates it in its own managed adapters for exactly this reason.
public final class HubPlugin extends JavaPlugin {
private static final String LOBBY_GROUP = "lobby";
private AtlasClient atlas;
@Override public void onEnable() {
try {
// Reads ATLAS_SDK_SOCKET and ATLAS_SERVER_ID, injected by the agent.
atlas = AtlasClient.connect();
// Without this, a /hub in the first second cannot tell
// "no lobbies exist" from "not loaded yet".
atlas.awaitReady(Duration.ofSeconds(5));
} catch (IOException e) {
getLogger().severe("Atlas unavailable, /hub disabled: " + e.getMessage());
getServer().getPluginManager().disablePlugin(this);
}
}
@Override public void onDisable() {
if (atlas != null) {
try { atlas.close(); }
catch (IOException e) { getLogger().warning(e.getMessage()); }
}
}
@Override public boolean onCommand(CommandSender sender,
Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only a player can be sent to a lobby.");
return true;
}
// Local read. No socket, safe on the tick loop.
if (atlas.routableServers(LOBBY_GROUP).isEmpty()) {
player.sendMessage("No lobby is accepting players right now.");
return true;
}
// Central claim: reserve a seat, then move the player.
atlas.player(player.getUniqueId())
.transfer(LOBBY_GROUP)
.whenComplete((ignored, error) ->
Bukkit.getScheduler().runTask(this, () -> {
// Now on the main thread.
if (error == null || !player.isOnline()) {
// Success needs no message — they are already gone.
return;
}
player.sendMessage(explain(error));
}));
return true;
}
private String explain(Throwable error) {
Throwable cause = error instanceof CompletionException
? error.getCause() : error;
// Retryable means "valid request, not right now" — every
// lobby full. Non-retryable means repeating it will not help.
if (cause instanceof AtlasException e && e.retryable()) {
return "Every lobby is full. Try again in a moment.";
}
getLogger().warning("/hub failed: " + cause);
return "Could not send you to a lobby.";
}
}
Needs a plugin.yml declaring main: com.example.hub.HubPlugin and a hub command. Group name lobby is whatever you called yours.
Core API
Every query below is thread-safe and reads memory in the current process. Values describe the whole Atlas network, not just the current node, and are normally about one second behind.
playerCount(group)Players across every server in a group.availableSlots(group)Advisory free slots across joinable servers.networkPlayerCount()True total from proxy groups, without double-counting backends.servers(group)All projected servers in one group.servers()All projected servers in the network.routableServers(group)Ready, accepting, addressable servers.self()This server’s view, or null before the projection.Seeing one available slot does not make it yours. Call reserveSlot before moving a player.
int players = atlas.playerCount("lobby");
int capacity = atlas.availableSlots("lobby");
List<ServerView> targets =
atlas.routableServers("lobby");
for (ServerView server : targets) {
logger.info("%s — %d/%d".formatted(
server.getServerId(),
server.getPlayerCount(),
server.getSlotCapacity()));
}
Core API
Atlas does not inspect your game. Report the facts that drive placement and lifecycle decisions: current occupancy, successful arrivals, and your game status.
Status names are yours. Your group configuration decides whether a value such as INGAME stops new placements and starts replacement capacity.
playerArrived releases the short-lived hold and reports the authoritative backend count in one operation.
// Match begins: remain healthy, stop taking players.
atlas.setStatus("INGAME");
// A reserved player reaches the backend.
int count = Bukkit.getOnlinePlayers().size();
atlas.playerArrived(player.getUniqueId(), count);
// Match ends: restore ordinary joinability.
atlas.clearStatus();
Core API
onEvent derives events from the same projection queries read. Delivery is at-most-once, not durable, and updates may be coalesced. Treat events as a prompt to inspect state—not as a ledger.
ServerStarting, ServerReady, ServerStopping, ServerStopped, ServerStatusChanged, and ServerJoinabilityChanged.
PlayerJoinedNetwork, PlayerLeftNetwork, and PlayerSwitchedServer.
Payments, punishments, and inventory writes need durable application state. Atlas lifecycle events can be missed during a disconnect.
atlas.onEvent(event -> {
// Callback runs on the SDK reader thread.
Bukkit.getScheduler().runTask(plugin, () -> {
if (event instanceof
AtlasEvent.ServerReady ready) {
announce("Ready: "
+ ready.server().getServerId());
}
});
});
Core API
atlas.player(uuid) returns a handle whether the player is online or not. Presence stays central, so location and transfer methods are asynchronous.
locate()Returns proxy, server, group, node, and placement time.isOnline()Checks for a live presence record.transfer(group)Reserves capacity, then asks the holding proxy to move.transferTo(server)Moves to an exact server without creating a reservation.send(payload)Fire-and-forget delivery to the current holder.Messages perform no location lookup. The server holding the player owns a live subscription; when that connection disappears, the route disappears with it.
AtlasPlayer player = atlas.player(playerId);
player.locate().thenCompose(presence -> {
if (!presence.online()) {
return CompletableFuture.failedFuture(
new IllegalStateException("offline"));
}
return player.transfer("squidgame");
}).exceptionally(error -> {
logger.warning(error.getMessage());
return null;
});
player.send("The next match is ready.");
Core API
Use atlas.players(…) instead of looping over individual players. A party request considers everyone in one central operation, so separate reservations cannot scatter the group by accident.
ALL_OR_NOTHINGOne server fits everyone, or the request fails.PREFER_TOGETHERTry one server first, then split across the best options.SPLITPlace each member using the group strategy.A completed party transfer may still contain unplaced players. Always inspect isComplete(), placed(), and unplaced().
AtlasParty party = atlas.players(memberIds);
party.transfer("squidgame",
PartyPolicy.PREFER_TOGETHER)
.thenAccept(result -> {
if (!result.isComplete()) {
result.unplaced().forEach((id, reason) ->
logger.warning(id + ": " + reason));
}
});
Core API
Atlas draws and routes NPCs itself, in the managed Paper adapter. Nothing below is needed to make them work — it is here for a plugin that wants to place or edit one from its own code, or to react when the set changes.
An NPC belongs to a group, not a server. Every server in a group is built from the same template and so runs the same world, which is what makes one placement appear on all of them. A change is stored centrally and pushed straight back out, so placing one on lobby-1 puts it on lobby-2 a moment later with nothing restarting.
npcs()The NPCs this server should be showing. Empty when the module is off.resolveNpcRoute(UUID, String)Resolves ingress-proxy and remembered-group rules centrally. The managed adapter calls this for NPC clicks.onNpcs(Consumer)Called immediately and whenever the set changes.createNpc(NpcPlacement)Places one where you are standing, in this server’s group.updateNpc(NpcEdit)Changes only the fields the edit set.deleteNpc(String)Removes one from every server in its group.NpcEdit is a builder rather than a record for exactly this reason: setting a display name must not silently reset the skin, the item and the click actions to defaults.
An NPC is created in the group of the server that asked, which the agent reads from its own registry. A plugin that could name a group could put an NPC in somebody else’s lobby, so there is no parameter for it.
Skins are resolved once by the control plane and stored, so no server ever calls Mojang — twenty lobbies rendering the same NPC would otherwise be twenty requests to a rate-limited endpoint for one answer. An unknown username fails the call by name; Mojang being unreachable places the NPC with the default skin rather than refusing.
Operators place NPCs in game with /atlasnpc, which needs the Minecraft permission atlas.npc.manage. Everything except placing and moving can also be edited from the dashboard.
Where a click sends a player is a JSON document on the NPC, evaluated centrally. NPCs & routing rules covers the commands, the rule format, and worked examples.
// React when Atlas pushes a new set.
atlas.onNpcs(config -> Bukkit.getScheduler().runTask(this, () -> {
getLogger().info("now showing " + config.getNpcsCount());
}));
// Place one where the player stands.
Location at = player.getLocation();
atlas.createNpc(new NpcPlacement(
at.getWorld().getName(), at.getX(), at.getY(), at.getZ(),
at.getYaw(), at.getPitch(), "&aSkyblock", "Notch"))
.thenAccept(id -> player.sendMessage("placed " + id));
// Change one field. Everything unsaid keeps its stored value.
atlas.updateNpc(NpcEdit.of(npcId).skinOwner("jeb_"));
Callbacks arrive on the SDK reader thread. Touching the world or a player from there is a race — hand it to the scheduler.
Reliability
The client reconnects indefinitely with capped exponential backoff and jitter. After reconnecting, it identifies itself again and replays held players, player count, and status.
Requests already in flight fail with a retryable AtlasConnectionException. Issue a fresh request after the connection-state callback reports true.
The Paper adapter can shut down a backend safely after a sustained agent outage. The default grace is five minutes, followed by a 30-second player warning and save. Set ATLAS_ORPHAN_SHUTDOWN_GRACE_SECONDS to at least 120 seconds, or use 0/off to disable it.
atlas.onConnectionStateChanged(connected -> {
logger.info("Atlas agent: "
+ (connected ? "connected" : "reconnecting"));
});
atlas.onDisconnectedFor(Duration.ofMinutes(2),
() -> scheduler.execute(this::enterSafeMode));
if (atlas.isConnected()) {
submitPlacement();
}
Reliability
AtlasException means the agent or control plane rejected an operation. Inspect code() and retryable(). AtlasConnectionException is an I/O failure on the local agent connection and is always safe to retry after reconnection.
Async failures normally arrive wrapped by CompletionException. Unwrap the cause before classifying it.
INVALID_ARGUMENTNOT_FOUNDCONFLICTUNAVAILABLETIMEOUTSEQUENCE_GAPINTERNAL
atlas.reserveSlot(group, playerId)
.whenComplete((slot, failure) -> {
if (failure == null) {
connect(slot.serverId());
return;
}
Throwable cause = failure.getCause() == null
? failure : failure.getCause();
if (cause instanceof AtlasException e
&& e.retryable()) {
enqueueForLater(playerId);
}
});
Reliability
Event, message, connection, and transfer callbacks run on the SDK reader/reconnect thread. Hand platform work to Bukkit, Velocity, or BungeeCord’s scheduler before touching their APIs.
Do not block a callback. It shares the connection path that delivers events and completes claim replies.
atlas.onPlayerMessage((playerId, payload) ->
Bukkit.getScheduler().runTask(plugin, () -> {
Player player = Bukkit.getPlayer(playerId);
if (player != null) {
player.sendMessage(new String(payload,
StandardCharsets.UTF_8));
}
})
);
Reference
The packaged adapters implement the platform-specific bridge around the same Java client.
Reports player counts and arrivals, exposes atlas() and setStatus(), delivers messages on the Bukkit scheduler, and owns orphan safety shutdown.
Synchronizes projected backends, resolves initial placement, reports presence, executes cross-network transfers, and publishes the Atlas player count in pings.
Provides the same placement, presence, transfer, messaging, and ping behavior through BungeeCord’s scheduler and event model.
Reference
ServerViewProjected protobuf view: IDs, group and node, lifecycle state, players, capacity, status, joinability, address, port, kind, and observation time.
SlotreservationId, serverId, expiresAtUnixMs, plus expiresAt() and isExpired().
PlayerPresenceonline, proxyId, serverId, groupId, nodeId, and sinceUnixMs.
JoinPlacementThe selected groupId, a live slot, and whether the player’s remembered group was used.
PartyPlacementPrimary server, expiry, per-player slots, and per-player failure reasons. Helpers report completeness and togetherness.
TransferRequestThe player, target server, and optional reservation that a proxy adapter must execute on its platform thread.
Reference
static connect()awaitReady(Duration)static connect(Path, String)playerCount(String)availableSlots(String)networkPlayerCount()servers(String)servers()routableServers(String)self()setStatus(String)clearStatus()setPlayerCount(int)playerArrived(UUID[, int])player(UUID)players(Collection|UUID…)holdPlayer(UUID)releasePlayer(UUID)onPlayerMessage(BiConsumer)onPlayerTransfer(Function)reportPresence(…)reportPlayerLeft(UUID, String)reserveSlot(String, UUID)reserveParty(…)resolveJoin(UUID)onEvent(Consumer)isConnected()onConnectionStateChanged(Consumer)onDisconnectedFor(Duration, Runnable)close()npcs()onNpcs(Consumer)createNpc(NpcPlacement)updateNpc(NpcEdit)deleteNpc(String)uuid()locate()isOnline()transfer(String)transferTo(ServerView)send(byte[])send(String)members()reserve(String[, PartyPolicy])transfer(String[, PartyPolicy])Generated protocol types live under com.atlasgg.protocol.v1. Application-facing helpers live under com.atlasgg.sdk.
reserveSlot or a concept such as events.