A production-ready ecosystem for creating and managing scalable, instanced Minecraft minigames, built on a modern, decoupled, and self-healing architecture.
The Atlas ecosystem is designed to solve the hardest problems of running a Minecraft network, server management, matchmaking, and performance, so that developers can focus purely on creating fun and unique game experiences. By leveraging a standalone orchestrator and a message-driven architecture, Atlas provides a robust and scalable foundation for any minigame network.
- π Dynamic Server Lifecycle: Game servers automatically register themselves on startup and gracefully deregister on shutdown. Stale or crashed servers are automatically culled by the Orchestrator.
- π Asynchronous Matchmaking: A fully asynchronous, queue-based matchmaking system (
/play <game>) ensures the network remains responsive under heavy load. - π‘ Zero-Config for Developers: Game developers focus purely on game logic. The engine handles all networking, server registration, and matchmaking automatically based on conventions.
- βοΈ Resilient & Self-Healing: If the Orchestrator restarts, game servers detect they are unknown and will automatically re-register themselves, healing the network state.
- π§± Robust State Machine: Built on the State Pattern, ensuring clean separation of logic for
Lobby,Countdown,In-Game, andPost-Gamestates. - π€ Integrated Team System: A simple, fluent API for creating teams, auto-balancing players, and managing friendly fire via automatic Bukkit
Scoreboardintegration. - πΊοΈ Performant World Management: Asynchronous loading of random maps from a template directory, world protection, and efficient, file-based map resetting for fast game turnover.
- π§ Flexible Configuration: A two-tiered config system allows developers to define core game rules in a main
config.ymland map-specific spawns and properties inmap.ymlfiles.
The Atlas ecosystem operates on an event-driven, message-based architecture that ensures scalability and resilience. Below is a step-by-step breakdown of the two primary workflows.
This flow describes how a new game server comes online and makes itself known to the entire network.
Initiation (Nova Engine): A Spigot server starts. A developer's game plugin registers their
Gameobject with the Nova Engine. The engine's State Machine transitions to theLobbyState.Message Creation (Nova Engine): The
LobbyState.onEnter()method triggers theAtlasIntegrationService. It constructs a globally unique Server ID (e.g.,spleef-game1-a4b9c1) and creates aServerLifecycleMessagewith the typeREGISTER.Broadcast (RabbitMQ): The message is published to a
fanoutexchange namedserver_lifecycle. A fanout exchange broadcasts the message to all consumers subscribed to it.Reception (AtlasLink & Orchestrator): Both
AtlasLink(on BungeeCord) and theAtlas Orchestratorare subscribed to theserver_lifecycleexchange. They both receive the exact sameREGISTERmessage simultaneously.Action (AtlasLink): Upon receiving the message,
AtlasLinkdynamically adds the new server's information (including its unique ID and address) to the BungeeCord proxy's server list. Players can now be connected to it.Action (Orchestrator): The Orchestrator adds the new server to its internal, thread-safe map of active servers. It now considers this server available for matchmaking.
Heartbeating (Nova Engine): The
NovaEnginestarts a periodic, asynchronous task to send its current player count and game state to the Orchestrator via an HTTP POST request, keeping its status up-to-date.
This flow describes what happens when a player types /play. It follows a classic Remote Procedure Call (RPC) pattern over a message queue.
Request (Nova Engine): A player on any Spigot server (e.g., a lobby) runs
/play spleef. TheMatchmakingServicein the Nova Engine does two things: > * It creates a temporary, exclusive reply queue for this specific server instance.
- It creates a
MatchmakingRequestmessage containing the player's UUID, the requested game type (spleef), and the name of its private reply queue.Queuing (RabbitMQ): The request is published to a
directqueue namedjoin_requests. This is a work queue, meant to be consumed by one of the available Orchestrator instances.Processing (Orchestrator): The Orchestrator, the sole consumer of the
join_requestsqueue, picks up the message. It runs its matchmaking algorithm: filtering its list of known servers by the requested game type, ensuring they are in a joinable state, and finding the one with the fewest players.Response (Orchestrator): The Orchestrator creates a
MatchmakingResponsemessage containing the target server's unique ID (ornullif no server was found). It then publishes this response not to the main queue, but directly to thereplyToQueuespecified in the original request.Reception (Nova Engine): The
MatchmakingServiceon the lobby server, which has been listening on its private reply queue, receives the response.Action (Nova Engine): It finds the player by their UUID and uses the BungeeCord plugin messaging channel (
Connect) to send the player to the target server ID. BecauseAtlasLinkalready knows about this server, the connection succeeds instantly.
-
Prerequisites: You need a running RabbitMQ server. The easiest way is with Docker:
docker run -d -p 5672:5672 -p 15672:15672 rabbitmq:3-management
-
Download Releases: Go to the Releases Page of this repository and download the latest JARs.
-
Run the Atlas Orchestrator: This is a standalone application.
java -jar atlas-orchestrator-1.0.0-all.jar
-
Install AtlasLink: Place
atlas-link-1.0.0.jarin your BungeeCordplugins/folder. -
Install Nova Engine: Place
nova-engine-1.0.0-ENGINE.jarin your Spigot serverplugins/folder. You will need one instance of this JAR for every Spigot server in the network. -
Install a Game: Place a game built with the engine (e.g.,
SpleefGame.jar) in the same Spigot folder. -
Configure Spigot: Set a unique
server-namein each Spigot instance'sserver.propertiesfile (e.g.,game-1,lobby-main). This is crucial for traceability.
To connect the components, you must provide them with your RabbitMQ server details.
The Orchestrator requires a config.json file to be present in the same directory where you run the JAR file.
- Create a file named
config.json. - Place it next to
atlas-orchestrator-1.0.0-all.jar. - Add the following content and fill in your details:
{
"rabbitmq": {
"host": "localhost",
"port": 5672,
"username": "guest",
"password": "guest"
}
}The BungeeCord and Spigot plugins will generate their own config.yml files upon first run.
- Start your BungeeCord and Spigot servers once with the plugins installed to generate the default configuration files.
- Stop the servers.
- Navigate to
plugins/AtlasLink/config.ymlandplugins/NovaEngine/config.ymland fill in your RabbitMQ details.
File Location: plugins/AtlasLink/config.yml
# Configuration for AtlasLink
rabbitmq:
host: 'localhost'
port: 5672
username: 'guest'
password: 'guest'File Location: plugins/NovaEngine/config.yml
# Configuration for the Nova Engine
rabbitmq:
host: 'localhost'
port: 5672
username: 'guest'
password: 'guest'Building a game is designed to be simple and intuitive. The engine handles all the complex backend integration for you.
Add the nova-engine.jar to your project's libs folder and configure your build.gradle.kts:
// build.gradle.kts
dependencies {
// Tell Gradle to use the engine for coding, but not to bundle it.
compileOnly(files("libs/nova-engine-1.0.0-ENGINE.jar"))
compileOnly("org.spigotmc:spigot-api:1.21.1-R0.1-SNAPSHOT")
}Then, tell Spigot that your plugin depends on the engine in your plugin.yml:
# plugin.yml
name: MyAwesomeGame
main: com.mygame.MyPlugin
version: 1.0
api-version: '1.21'
# This is critical for ensuring correct load order!
depend: [NovaEngine]Extend the Game class to define your minigame's core rules and provide the engine with your custom game states.
package com.mygame;
import fr.arnaud.nova.api.Game;
import fr.arnaud.nova.api.state.GameState;
import fr.arnaud.nova.internal.state.EndState; // You can reuse the engine's default states!
import org.jetbrains.annotations.NotNull;
import org.bukkit.ChatColor;
public class MyAwesomeGame extends Game {
@Override @NotNull
public String getgame() { return "awesome"; }
@Override
public int getMinPlayers() {
// Read from your own config.yml for flexibility!
return getConfig().getInt("game-settings.min-players-to-start", 2);
}
@Override
public int getMaxPlayers() { return 8; }
// Factory method to provide your custom in-game logic.
@Override @NotNull
public GameState getInGameState() {
return new MyAwesomeGameState(this);
}
// Factory method for the end state. We can reuse the default one!
@Override @NotNull
public GameState getEndState() {
return new EndState(this);
}
@Override
public void onPostLoad() {
// This is a great place to set up teams.
createTeam("hunters", "Hunters", ChatColor.RED).setMaxSize(2);
createTeam("runners", "Runners", ChatColor.BLUE).setMaxSize(6);
}
}Extend the abstract GameState class to define what happens during a specific phase of your game.
package com.mygame;
import fr.arnaud.nova.api.state.GameState;
import fr.arnaud.nova.api.Game;
import fr.arnaud.nova.core.NovaPlugin;
import org.bukkit.event.EventHandler;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
public class MyAwesomeGameState extends GameState {
public MyAwesomeGameState(Game game) {
super(game);
}
@Override
public void onEnter() {
// Game is starting! Disable world protection.
NovaPlugin.getInstance().getEngine().getWorldManager().setProtection(false);
// Teleport players, give items, etc.
}
@Override
public void onExit() {
// The game is over, re-enable protection as a failsafe.
NovaPlugin.getInstance().getEngine().getWorldManager().setProtection(true);
}
@EventHandler
public void onPlayerDamage(EntityDamageByEntityEvent event) {
// Your custom PvP logic here.
// If a win condition is met...
if (/* someone won */) {
// Tell the engine to transition to the end state.
NovaPlugin.getInstance().getEngine().getGameManager().transitionTo(game.getEndState());
}
}
// Implement other required methods (onTick, onPlayerJoin, onPlayerQuit, getEnum)...
}Finally, in your main plugin class, tell the Nova Engine about your game.
package com.mygame;
import fr.arnaud.nova.core.NovaPlugin;
import org.bukkit.plugin.java.JavaPlugin;
public class MyPlugin extends JavaPlugin {
@Override
public void onEnable() {
// Pass 'this' so the engine can load your config.yml
NovaPlugin.getInstance().getEngine().getGameManager()
.registerGame(new MyAwesomeGame(), this);
}
}That's it! Your minigame is now a fully integrated part of the Atlas network.
Beyond the basics, the Nova Engine provides powerful, convention-based systems for managing game maps and teams. Hereβs how to use them.
The engine automatically discovers, selects, and loads maps for your game. All you need to do is follow a simple folder structure.
Inside your Spigot server's folder, create the following directory structure. The engine will scan the <game> folder that matches the one returned by your Game class.
plugins/
βββ NovaEngine/
βββ maps/
βββ <game>/ (e.g., "spleef")
β βββ <map_name_1>/ (e.g., "classic_arena")
β β βββ region/
β β βββ data/
β β βββ map.yml <-- Map-specific configuration
β βββ <map_name_2>/ (e.g., "ice_rink")
β βββ ...
β βββ map.yml
βββ <another_game>/ (e.g., "skywars")
βββ ...
Each map folder must contain a map.yml file to define its properties and spawn points.
Example: plugins/NovaEngine/maps/spleef/classic_arena/map.yml
# The friendly name for broadcasts and scoreboards.
display-name: "Classic Arena"
# The location players are sent to in the lobby/waiting state.
# Format: "world,x,y,z,yaw,pitch". The 'world' is ignored and will be the active game world.
lobby-spawn: "world,0,100,0,90,0"
# A list of spawn points for each team.
# The team IDs ('red', 'blue') MUST match the IDs you create in your Game class.
team-spawns:
red:
- "world,-20,80,0,-90,0"
- "world,-22,80,2,-90,0"
- "world,-20,80,4,-90,0"
blue:
- "world,20,80,0,90,0"
- "world,22,80,-2,90,0"
- "world,20,80,-4,90,0"The engine loads the map data before your game starts. You can access it through the WorldManager to teleport players.
// In your custom InGameState.java
import fr.arnaud.nova.api.world.GameMap;
import org.bukkit.Location;
@Override
public void onEnter() {
GameMap currentMap = NovaPlugin.getInstance().getEngine().getWorldManager().getCurrentMap();
for (GamePlayer gamePlayer : NovaPlugin.getInstance().getEngine().getPlayerManager().getPlayers()) {
GameTeam team = gamePlayer.getTeam();
if (team != null) {
// Get the list of spawns for this player's team.
List<Location> teamSpawns = currentMap.getTeamSpawns().get(team.getId());
if (teamSpawns != null && !teamSpawns.isEmpty()) {
// Teleport the player to a random spawn point for their team.
Location spawnPoint = teamSpawns.get(new Random().nextInt(teamSpawns.size()));
gamePlayer.getBukkitPlayer().teleport(spawnPoint);
}
}
}
}The engine provides a fluent API to create teams and automatically handles scoreboard integration (name tags, friendly fire).
The best place to create teams is in your Game class's onPostLoad() method, which runs after the configuration is loaded but before players join.
// In your MyAwesomeGame.java
@Override
public void onPostLoad() {
// The createTeam method returns the GameTeam object, allowing you to chain methods.
createTeam("red", "Red Team", ChatColor.RED).setMaxSize(8);
createTeam("blue", "Blue Team", ChatColor.BLUE).setMaxSize(8);
}When a player joins, you can either assign them to a team automatically or manually. This is typically done in the onPlayerJoinGame hook.
// In your MyAwesomeGame.java
@Override
public void onPlayerJoinGame(GamePlayer player) {
// The engine will find the smallest available team and assign the player.
autoAssignTeam(player);
GameTeam team = player.getTeam();
if (team != null) {
player.getBukkitPlayer().sendMessage("You have joined the " + team.getColor() + team.getDisplayName());
}
}You can easily get a player's team to implement game logic like friendly fire checks.
// In your custom InGameState.java
@EventHandler
public void onPlayerDamage(EntityDamageByEntityEvent event) {
if (event.getEntity() instanceof Player victim && event.getDamager() instanceof Player attacker) {
GamePlayer gameVictim = NovaPlugin.getInstance().getEngine().getPlayerManager().getPlayer(victim);
GamePlayer gameAttacker = NovaPlugin.getInstance().getEngine().getPlayerManager().getPlayer(attacker);
if (gameVictim != null && gameAttacker != null && gameVictim.getTeam() != null) {
// Check if players are on the same team.
if (gameVictim.getTeam().equals(gameAttacker.getTeam())) {
// Cancel friendly fire!
event.setCancelled(true);
attacker.sendMessage(ChatColor.RED + "You can't hurt your teammates!");
}
}
}
}