đźź Agar.io Clone with Akka.
During the preparation of this work, the authors used Chat-GPT and Gemini to refine the report by improving sentence structure and correcting grammatical errors. After using these tools, the authors reviewed and edited the content as needed and takes full responsibility for the content of the final report.
Agar.io is an online, massively multiplayer action game. Players take on the role of a small, circular cell inside a map that resembles a Petri dish. The primary goal is to grow as large as possible by consuming smaller cells, both those controlled by other players and those that are scattered randomly throughout the game world as food. This simple premise leads to a dynamic and competitive environment where a player’s size directly dictates their power and vulnerability. Players can join a randomly assigned game session, create a new session, or join an existing one using a unique session ID. The session ends only when the player is eaten or decides to quit.
The goal of this project is to design and develop a clone of Agar.io with a robust client–server architecture, ensuring scalability, performance, and smooth multiplayer interaction.
Achievements:
This project is a desktop application with a graphical user interface (GUI) packaged as a Java Archive (JAR) file. It can be executed on any system with a Java Runtime Environment (JRE) installed.
This project employs a client-server architecture with a distributed system design. The architecture consists of three main components: the Client, the Mother Server, and multiple Child Servers.
The system architecture consists of three main components:
Client service:
Mother Server service:
Child Server service:
This architecture allows for scalability, as multiple Child Servers can be added to handle more game sessions as needed. The Mother Server is the seed node of the system and must always be started first. Child Servers can be started and stopped dynamically, allowing for flexible resource management. Child Servers can be hosted on different machines or cloud instances to distribute the load effectively, it’s not mandatory to have them on the same network or datacenter as the Mother Server.
The client, which has a graphical user interface (GUI), is structured according to the Model-View-Controller (MVC) architectural pattern, which separates the application into three interconnected components: the Model, the View, and the Controller.
The Model encapsulates the game logic and state management:
The Controller handles interactions and updates between the model and the view:
The View presents the game to the user:
The Mother Server coordinates the main management logic:
Child Servers manage individual game sessions and receive players from the Mother Server.
As seen in the class diagrams above, there are a set of classes shared between the Mother Server and Child Server components. These classes are used to represent the world state of a game session and they are the body of the messages exchanged to synchronize the game state between the servers and the clients and also between the Mother Server and Child Servers. They include:
MembersManager continuously monitors the network.
ClientActor orchestrates the entire lifecycle of the client-side logic: it initializes the UI, discovers the cluster, requests a game session, and runs a synchronized game loop with the server. It transitions between two main modes:
viewBehavior
): discovery, matchmaking, and session acquisition.run
): simulation–render–synchronization loop with the Child Server.It also processes user actions (e.g., JoinRandomRoom) and handles end-of-game transitions.
When apply()
is invoked:
CLIENT_SERVICE_KEY
.JoinNetwork
.viewBehavior(view)
.At this stage, the actor becomes discoverable and begins reacting to cluster state changes.
In this mode manages all events prior to entering a game session. It may hold an optional reference to the Child Game Manager (manager: ActorRef[ChildEvent]
).
JoinNetwork(MemberUp)
GameManagerAddress(managerRef)
viewBehavior(view, Some(managerRef))
.LocalClientEvent.JoinRandomRoom
requestWorld(nickName, ctx, manager)
.ServiceNotAvailable()
LocalClientEvent.ReceivedWorld(world, player, managerRef)
ImmutableGameStateManager
and LocalView
.Tick
to itself to start the game loop in the run
behavior.This mode handles the main game loop and implements a deterministic, synchronized update pattern.
A timer triggers Tick
every 50 ms.
The isSynced
flag ensures only one update is in-flight between client and server:
isSynced = true
→ the client processes input and sends an update.isSynced = false
→ the client waits for the server’s response before proceeding.LocalClientEvent.Tick
(only when isSynced = true
)
gameView
.movePlayerDirection
and tick()
).EatenPlayer
messages).RequestRemoteWorldUpdate
.isSynced = false
to await a response.ReceivedRemoteWorld(remoteWorld)
isSynced = true
.EndGame()
EndGameView
.endGame()
state.MotherActor
functions as the central coordinator and matchmaker in the Raga.io distributed architecture. It does not manage gameplay logic directly but is responsible for:
The actor operates as a stateful event handler that reacts to cluster events and client lifecycle messages, maintaining a dynamic view of the system through its internal MotherState
.
When apply()
is invoked:
MOTHER_SERVICE_KEY
.MembersManager
actor, which monitors the cluster and forwards membership events.MotherState
, containing no children and no pending clients.At this stage, the MotherActor
is ready to accept and respond to system-level events.
The actor maintains two key data structures inside MotherState
:
children
: a list of active ChildState
objects, each representing a connected child server, its assigned clients, and the associated world ID.pendingClients
: a list of clients waiting for assignment because no child servers are currently available.The actor transitions through different states purely based on incoming messages:
ClientUp(client)
findFreeChild
.
ServiceNotAvailable()
to the client.pendingClients
for future assignment.GameManagerAddress(child.ref)
to the client.ClientLeft(client)
pendingClients
.ChildClientLeft(client)
.ChildServerUp(child)
worldId
using generateWorldID
.SetUp(worldId)
to the new child server to initialize its game session.GameManagerAddress(child)
.children
list with its worldId
and an empty client list.ChildServerLeft(child)
children
list.MembersManager
and client retry logic).The MotherActor
maintains consistent global state by:
The actor’s state is updated immutably: every behaviour invocation constructs a new MotherState
copy with the updated lists.
The actor uses a simple least-loaded server selection algorithm to distribute clients:
findFreeChild(state)
sorts the list of children by the number of connected clients.This ensures even load distribution and minimizes performance bottlenecks across child servers.
ChildActor
is the authoritative game-session host.
It owns the world state for a single room, handles player joins/leaves, merges client-submitted updates, and broadcasts the authoritative world to all connected clients.
It never renders; it only validates/merges state and notifies clients.
When apply()
is invoked:
CHILD_SERVICE_KEY
.SetUp(worldId)
.SetUp(worldId)
, it initializes the authoritative Worldwork(world, managedPlayers = Map.empty)
.At this point, the child server is ready to accept clients and process gameplay messages.
This behaviour maintains two authoritative structures:
playerId → ActorRef[ClientEvent]
used for targeted responses and broadcasts.RequestWorld(nickName, replyTo, playerRef)
RemoteWorld(newWorld, newPlayer)
to the requester.RequestRemoteWorldUpdate(updatedWorld, (playerId, playerRef))
mergeWorlds(oldWorld, updatedWorld, playerId)
:
oldWorld
.updatedWorld
.updatedWorld
and replenishes items to keep density stable.ReceivedRemoteWorld(mergedWorld)
to all clients in managedPlayers
.ChildClientLeft(clientRef)
playerId
by reverse lookup on managedPlayers
.managedPlayers
and world.players
.ReceivedRemoteWorld(newWorld)
to remaining clients.work(newWorld, newManagedPlayers)
.EatenPlayer(playerId)
world.players
.ReceivedRemoteWorld(newWorld)
to all clients.managedPlayers
, sends EndGame()
directly to their client; otherwise logs a miss.work(newWorld, managedPlayers)
.On merge, the logic is:
oldWorld
.newWorld
.newWorld.foods
(as observed/consumed client-side).extraFoods = generateFoods(INIT_FOOD_NUMBER) minus existing ids
.oldWorld.id
and default dimensions.Broadcasts of ReceivedRemoteWorld
are fanned out to all managedPlayers
using a short-lived anonymous actor.
This avoids blocking the main child behaviour on per-client messaging and confines the broadcast to a small, stoppable context.
The game is designed for temporary sessions, and no persistent data storage is required. All game state data, including player positions, sizes, and food items, are maintained in memory during the session. Once a session ends (either by player elimination or voluntary exit), all associated data is discarded.
ClientActor
subscribes to cluster membership updates (JoinNetwork(MemberUp)
).MotherActor
spawns a MembersManager
that monitors the cluster.MotherActor
replies with ServiceNotAvailable()
and queues clients in pendingClients
.ChildServerUp
), pending clients are assigned.MotherActor
handles ClientLeft
by removing the client and notifying the relevant child with ChildClientLeft
.ChildActor
removes the player from the world and broadcasts an authoritative snapshot to remaining players.ReceivedRemoteWorld
.ChildActor
keeps its world in-memory only.MotherActor
tracks routing metadata (children, pending clients) but does not store game state.The software is designed to be highly available, with a distributed architecture that allows multiple game servers to handle sessions concurrently.
The Mother Server acts as a central coordinator, managing the distribution of players to various Child Servers to avoid overloading any single server. This load balancing enhances availability by ensuring that if one server becomes overwhelmed, new players can be directed to less busy servers.
If one game server fails, players can be redirected to another server without losing their session (it’s mandatory to have a Child Server that is managing a game session).
There are not any kind of security mechanisms implemented, as the game is designed for casual play without sensitive data involved. Users are not required to create accounts or provide personal information, they can simply enter a nickname to join a game session. The system does not store any personal data, and all session data is temporary and discarded once the session ends.
We choose to use TCP (Transmission Control Protocol) for several key reasons, with reliability and order being the most critical. Unlike UDP, TCP is a “connection-oriented” protocol that ensures data packets are delivered, and if a packet is lost, it’s automatically resent. This is crucial for maintaining a consistent game state, especially for core mechanics like player movements, collisions, and cell consumption, where a lost packet could lead to players appearing to be in different locations or a desynchronized game world. Furthermore, TCP guarantees that packets arrive in the correct order, which is essential for deterministic game logic. UDP is favored for fast-paced shooters where low latency is paramount, the nature of an Agar.io clone tolerates slightly higher latency in exchange for the absolute reliability and synchronization that TCP provides.
Below is a snippet from the application.conf
file showing the configuration for using TCP as the transport protocol with Akka’s remote artery:
remote {
artery {
transport = tcp
}
}
To represent in-transit data, we opted for JSON (JavaScript Object Notation) produced by Akka’s Jackson serializer. The converse from entity to JSON and vice versa is handled automatically by Akka, which simplifies the serialization process.
serializers {
jackson-json = "akka.serialization.jackson.JacksonJsonSerializer"
}
serialization-bindings {
"akka.actor.typed.ActorRef" = jackson-json
"akka.actor.typed.internal.adapter.ActorRefAdapter" = jackson-json
"it.unibo.protocol.Message" = jackson-json
}
All data is not stored persistently, as the game is designed for temporary sessions.
Automatic tests were not implemented due to time constraints and the complexity of simulating real-time multiplayer interactions. However, manual testing was conducted extensively to ensure the system met the functional requirements.
Manual testing focused on the following areas:
Each release is packaged into three separate JAR files: one for the Mother Server, one for the Child Server, and one for the Client. This modular structure enables independent deployment and scaling of each component according to demand.
All JAR files follow semantic versioning (e.g., 1.0.0
, 1.1.0
, 2.0.0
), making it easier to track changes and maintain compatibility across components.
The JARs are distributed through a public GitHub repository, where users can download the latest versions. Each release is also tagged in the repository for convenient access.
Since the executables run on any system with a Java Runtime Environment (JRE), they are fully platform-independent. Currently, they are executed locally, but with proper configuration they can also be deployed on remote servers.
The installation process is straightforward:
java -jar <filename>.jar
.
To launch the entire system, follow this sequence (different order is also possible, but the Mother Server must always be started first):
The following instructions guide you through deploying the Raga.io system from scratch on your local machine - for remote deployment, additional configuration may be required.
To deploy, follow these steps:
Open a terminal and execute the following command:
java -jar mother-server-assembly-*-SNAPSHOT.jar
The Child Servers must be started next, using a similar command:
java -jar child-server-assembly-*-SNAPSHOT.jar
Finally, the Client application can be launched:
java -jar client-assembly-*-SNAPSHOT.jar
In the menu page, the user can:
In the game page, the user can: