Atlas Developers
Dashboard

NPCs · 1.0

Server-selector NPCs

Stand somewhere in your lobby, run one command, and a clickable character appears on every server in that group. Where each click sends the player is a routing document you write once — the same NPC can put an Australian on the SMP they played yesterday and a first-timer on the nearest one.

Placed in gameCoordinates come from where you stand
Group-wideOne placement, every server in the group
LiveChanges arrive without a restart
Routingau-lobby NPC
{
  "rules": [{
    "when": { "from_group_in": ["au-proxy"] },
    "send_to": {
      "strategy": "last_of",
      "groups": ["au-smp", "na-smp", "eu-smp"],
      "fallback": { "strategy": "lowest", "group": "au-smp" }
    }
  }],
  "default": { "strategy": "lowest", "group": "smp" }
}

Start

Turn the module on

NPCs are off until you switch them on, under Modules → NPCs on the dashboard. Choose whether every group renders them or only the ones you pick — a selector belongs in a lobby, not in the middle of a match.

Switching the module off hides every NPC without deleting one, so turning it back on restores exactly what you had. Enabling it with selected groups and no groups chosen is refused rather than saved: that combination looks configured and does nothing.

Nothing to install.

Atlas renders NPCs from its own managed Paper adapter, which it installs on your servers itself. There is no plugin to download and no dependency to add to a template.

Editing any of this needs the npcs.manage permission on your Atlas role. That is separate from the in-game permission below — one governs the person on the dashboard, the other the person standing in the world.

HTTP
# The dashboard does this for you; the API is here
# if you would rather script it.
curl -X PUT https://dashboard.atlasgg.com/v1/networks/$NETWORK/modules/npcs \
  -H "Authorization: Bearer $ATLAS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "target_mode": "selected",
    "target_groups": ["lobby", "au-lobby"]
  }'

Full endpoint details are in the HTTP API.

Start

Place one in game

Placing is a command, not a form. A coordinate typed by somebody who cannot see the world is a coordinate inside a wall, so /atlasnpc create uses where you are standing and which way you are facing.

The command needs the Minecraft permission atlas.npc.manage, which defaults to operators.

One placement, every server in the group.

An NPC belongs to a group rather than a server. Every server in a group is built from the same template and runs the same world, which is what makes one placement valid on all of them. Place one on lobby-1 and it is standing on lobby-2 a moment later, with nothing restarting.

You cannot place into somebody else’s group.

An NPC is created in the group of the server you ran the command on, read from the agent’s own registry. There is no parameter for it, on the command or in the SDK.

In game/atlasnpc
# Stand where it should go, face the way it should face.
/atlasnpc create &aSkyblock

# Every NPC in this group, with its id.
/atlasnpc list

# Editing by id. Tab-completion fills the ids in.
/atlasnpc name       <id> &bThe Hub
/atlasnpc skin       <id> Notch
/atlasnpc item       <id> DIAMOND_SWORD
/atlasnpc look       <id> true
/atlasnpc leftclick  <id> connect
/atlasnpc rightclick <id> gui

# Moves it to where you are standing now.
/atlasnpc move   <id>
/atlasnpc remove <id>

Start

Name, skin and clicks

Everything except placing and moving can also be edited on the dashboard, on the same page you enabled the module.

  • display_nameDrawn just above the NPC. Colour codes and placeholders both work.
  • skin_owner_nameAny Minecraft username. The skin is that account’s.
  • item_in_handA Bukkit material name, or AIR for empty-handed.
  • look_at_playerTurns its head to follow whoever is nearby.
  • info_linesHologram lines above the NPC. Colour codes and placeholders both work.
  • imitate_playerWear the viewer’s own skin instead of the configured one.
  • left_click_actionNOTHING, DIRECT_CONNECT, or OPEN_GUI.
  • right_click_actionThe same three, set independently.

Skins are resolved once, centrally, and stored with the NPC. Twenty lobbies rendering the same character would otherwise be twenty requests to a rate-limited Mojang endpoint for one answer. A username nobody owns is refused by name; Mojang being unreachable places the NPC with the default skin rather than losing your work.

The name is drawn, not worn.

A player’s nameplate is always white and never longer than sixteen characters, so Atlas switches that plate off and draws the name itself, just above the NPC’s head. Colour codes work, there is no length limit, and a name holding a live player count updates in place without the NPC blinking.

Clicks are per button.

Left and right are separate actions, so one NPC can connect on right-click and open the menu on left. Repeat clicks inside half a second are ignored, which stops a held mouse button firing a queue of transfers.

HTTP
curl -X PUT https://dashboard.atlasgg.com/v1/networks/$NETWORK/npcs/$NPC \
  -H "Authorization: Bearer $ATLAS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "&aSkyblock &7(%group_players:skyblock%)",
    "skin_owner_name": "Notch",
    "item_in_hand": "GRASS_BLOCK",
    "look_at_player": true,
    "right_click_action": "DIRECT_CONNECT",
    "routing": {
      "rules": [],
      "default": { "strategy": "lowest", "group": "skyblock" }
    }
  }'

The stored Mojang texture is never returned by the API — it is a large blob only the server plugin needs.

Routing

What happens when somebody clicks

Routing decides which group a player is sent to. Which server inside that group is a separate decision, made by that group’s own placement strategy against live capacity — so a routing document never names a server, and never has to be rewritten when you add one.

  1. The adapter catches the click and swallows it, so the server never sees an interaction with an entity it does not know about. A player clicking twice in half a second counts once.
  2. The routing document goes to the control plane. A backend cannot reliably know which proxy accepted this connection or where the player last played, so interpreting the rules on the server would make routing a guess. Both facts live centrally, and so does the answer.
  3. The plane returns one group name — or refuses, if nothing in the document applies to this player.
  4. The ordinary transfer path runs. It reserves a real slot against live capacity before moving anyone, exactly as transfer() does from a plugin. A group that filled up in the meantime fails honestly instead of dropping the player onto a full server.
Destinations must be live game groups.

Every candidate is checked against the network’s active game groups at click time. An archived group is skipped rather than returned, so retiring a season does not leave an NPC pointing into nothing.

Routing

The routing document

Every NPC carries one. The dashboard writes the simple case for you from the Destination dropdown; the text area underneath is the same document, for everything the dropdown cannot express.

Two keys

  • rulesChecked in order. The first whose condition matches wins.
  • defaultUsed when no rule matched. May be null.

One condition

when.from_group_in lists proxy group names, and matches when the proxy that accepted this player’s connection is in the list. It is how one NPC behaves differently for players who arrived on different regional proxies. Naming a game group here is allowed but can never match.

A matched rule does not fall through to default.

Once a rule’s condition matches, that rule owns the outcome. If its target resolves to nothing — every candidate group archived, no history for a new player — the click fails; it does not quietly try default instead. Put a fallback inside the rule for that case. This is the mistake worth checking for first when an NPC works for you and not for somebody else.

JSON
{
  "rules": [
    {
      "when": { "from_group_in": ["au-proxy", "nz-proxy"] },
      "send_to": { "strategy": "lowest", "group": "au-smp" }
    },
    {
      "when": { "from_group_in": ["eu-proxy"] },
      "send_to": { "strategy": "lowest", "group": "eu-smp" }
    }
  ],
  "default": { "strategy": "lowest", "group": "na-smp" }
}

An empty rules array with a default is the ordinary case: one NPC, one destination, everybody.

Routing

Strategies

A target — whether it is a rule’s send_to, the document’s default, or a fallback — names a strategy and the fields that strategy reads.

Every strategy takes an optional fallback, which is itself a whole target and is used when the strategy names nobody. Fallbacks may nest twice.

Strategies
StrategyReadsBehaviour
lowest group or groups The quietest destination. Given one group it names that group; given a groups list it picks whichever has the fewest players on it right now.
highest group or groups The busiest of the candidates — for funnelling people to where the game already is, rather than spreading them thin across empty servers.
random group or groups An even spread. Picks uniformly among the candidates that are live, ignoring population entirely.
last_of groups, fallback Sends the player back to the group they most recently played, considering only the groups you list. Falls through to fallback when none of them applies — a first-time player, or a list where everything has been archived.
Routing chooses the group; the group chooses the server.

Population strategies compare whole groups against each other. Which server inside the winning group a player lands on is that group’s own placement strategy, set on the group — so lowest across three SMPs and a group configured to fill its emptiest server are two different knobs doing two different jobs.

What last_of remembers

Two things, most recent first. While a player is online, the groups they have switched away from this session — up to 32 of them. When they disconnect, the group they were standing in is written down and survives the session. A player who has never been anywhere has neither, which is what fallback is for.

A fallback is a whole target, so it can be another last_of with a wider list. Nesting is capped at two levels beyond the first; a chain longer than that resolves to nothing rather than looping.

Population is read live, per click.

Counts come from the same heartbeats the dashboard draws, so a group that has just emptied is a valid answer a second later. A group whose count cannot be read is treated as empty rather than failing the click — and the transfer that follows still reserves a real slot, so nobody lands on a full server because a count was stale.

An unknown strategy resolves to nothing.

The four above are the whole list. A misspelling saves cleanly — the API rejects unknown strategies, but a document written directly into the database would not be checked — and then fails at click time with no usable destination.

Routing

Worked examples

One destination for everybody

What the dashboard dropdown writes. Use it for a hub NPC that always leads to the same place.

Send a returning player back

The one people ask for. A network with au-smp, na-smp and eu-smp wants one NPC in the lobby that returns you to whichever you were playing — and puts a brand-new player on the one nearest the proxy they arrived on.

Read the second example as: if you came in on the Australian proxy, send you to the most recent of these three SMPs; if you have played none of them, send you to au-smp. Anyone else gets the shared smp.

Spread or concentrate

random spreads arrivals evenly across interchangeable groups; highest does the opposite, sending people to the busiest so a quiet evening concentrates into one populated server instead of four lonely ones. lowest over a list is the middle ground.

Different lobbies, different NPCs

You do not have to solve everything in one document. NPCs are per group, so an au-lobby NPC can carry the Australian rules and a eu-lobby NPC its own — often clearer than one document with six rules in it.

There is no dry run.

Routing is evaluated against a real player’s real history, so the only honest test is clicking it. Test the new-player path with an account that has never joined the groups in the list — that is the branch that goes wrong quietly.

JSON
// 1. One destination for everybody.
{
  "rules": [],
  "default": { "strategy": "lowest", "group": "skyblock" }
}

// 2. Return an Australian player to the SMP they last
//    played; a new one starts on au-smp.
{
  "rules": [{
    "when": { "from_group_in": ["au-proxy"] },
    "send_to": {
      "strategy": "last_of",
      "groups": ["au-smp", "na-smp", "eu-smp"],
      "fallback": { "strategy": "lowest", "group": "au-smp" }
    }
  }],
  "default": { "strategy": "lowest", "group": "smp" }
}

// 3. Everyone goes back where they were, wherever they
//    connected from: no condition, just a default.
{
  "rules": [],
  "default": {
    "strategy": "last_of",
    "groups": ["au-smp", "na-smp", "eu-smp"],
    "fallback": { "strategy": "lowest", "group": "na-smp" }
  }
}

// 4. Spread arrivals evenly across four identical
//    minigame groups, whatever their populations.
{
  "rules": [],
  "default": {
    "strategy": "random",
    "groups": ["bedwars-1", "bedwars-2",
               "bedwars-3", "bedwars-4"]
  }
}

// 5. Send people where the game already is, so a quiet
//    evening concentrates instead of scattering.
{
  "rules": [],
  "default": {
    "strategy": "highest",
    "groups": ["au-smp", "na-smp", "eu-smp"]
  }
}

// 6. Regional split, with the returning-player rule kept
//    for the region that actually has three SMPs.
{
  "rules": [
    {
      "when": { "from_group_in": ["eu-proxy"] },
      "send_to": { "strategy": "lowest", "group": "eu-smp" }
    },
    {
      "when": { "from_group_in": ["au-proxy", "nz-proxy"] },
      "send_to": {
        "strategy": "last_of",
        "groups": ["au-smp", "na-smp"],
        "fallback": { "strategy": "lowest", "group": "au-smp" }
      }
    }
  ],
  "default": { "strategy": "lowest", "group": "na-smp" }
}

Group names are whatever you called yours. au-proxy must be a proxy group; every destination must be a game group.

Reference

Placeholders

Usable in an NPC’s display name. Values come from the plugin’s local copy of the Atlas registry — a memory read, not a network call — and refresh on their own. Replace <group> with one of your group names.

Available placeholders
PlaceholderScopeValue
%online_players%networkEveryone online, counted from proxy groups so backends are not double-counted.
%group_players:<group>%networkPlayers across every server in that group.
%group_available:<group>%networkFree slots across the joinable servers in that group.
%group_servers:<group>%networkHow many servers that group is currently running.
%name%viewerThe name of the player looking at it.
%ping%viewerTheir latency, in milliseconds.
%server%viewerThe server they are standing on.
%group%viewerThat server’s group.
%time%viewerThe server’s clock.

Viewer placeholders are rendered per player, so two people standing at the same NPC see different text.

Reference

When routing fails

A click that cannot be honoured tells the player so in chat, prefixed with Could not send you there. The rest of the message says which half failed.

  • no usable destinationThe document produced no group. Nothing matched and there is no default; or a rule matched and its target resolved to nothing; or a strategy name is one Atlas does not implement.
  • a capacity failureThe group was chosen but is full. Routing worked; there was nowhere to put them.
  • temporarily unavailableThe control plane could not read the player’s history. Retrying is reasonable.
Check the destination still exists.

Archiving a group is the usual cause of an NPC that worked last month. Candidates are filtered against live game groups every click, so an archived destination is skipped silently — and if it was the only one, the click has nothing left to return.

Java
// Atlas routes NPC clicks itself. This is only for a
// plugin doing its own routing — a custom menu, say.
atlas.resolveNpcRoute(player.getUniqueId(), routingJson)
    .thenCompose(group ->
        atlas.player(player.getUniqueId()).transfer(group))
    .whenComplete((ignored, error) -> {
        if (error != null) {
            // Callback is off the tick thread.
            Bukkit.getScheduler().runTask(plugin, () ->
                player.sendMessage("Nowhere to send you."));
        }
    });

The rest of the NPC surface is in the Java SDK reference.

Reference

Holograms and imitation

Hologram lines

Float above the NPC, first line highest, stacked above the display name — which is drawn the same way. Colour codes work and there is no length limit. Placeholders expand per viewer and retype in place, so a player-count line updates without the NPC flickering.

A blank line still takes up its place in the stack without drawing anything, which is how you put a gap between two groups of lines.

Imitate player

The NPC wears the skin of whoever is looking at it, which is possible because every NPC is drawn per viewer anyway. A viewer whose account carries no skin sees the NPC’s configured one.

Drawn text needs Minecraft 1.17 or newer.

Both the lines and the name are invisible marker entities, and the metadata that makes an entity invisible sits at a different index on older servers. Sending the wrong index disconnects the client rather than looking wrong, so below 1.17 Atlas draws neither and falls back to the ordinary nameplate — white, cut at sixteen characters, colour codes stripped. The server logs one line saying so at startup. Everything else about the NPC works normally.