Agario Clone
Simple Agar.io clone
Introduction
The current game files are being hosted on my GitHub repository.
After the first week of winter break, I had nothing to do so I poorly
slapped together a simple Agar.io clone in a week for a side project.
For my server, I utilized Python's Flask and Socket.io libraries for the client server Web Socket and API connections. The player and room indexing was supplied by a MySQL Docker container. The client side is made with HTML, CSS, and JS, specifically utilizing the P5.js library for rendering the game canvas.
The game in it's current state is not complete but the following features have been implemented:
- User creation
- Multiple Rooms and Chats
- Game rendering
- Entities spawning
- Entity absorbing interactions
Features below will be added eventually.
- Player splitting
- The Virus
- Player customization
Most of the rendering and game logic issues I had were quickly fixed by Andrew Li
Server Setup
Database
I utilized MySQL for the creation of players and rooms. As of right now, it doesn't do much.
For testing I setup the MySQL container using Docker Desktop. I had to set the root password environment variable to access the server.
MYSQL_ROOT_PASSWORD=123
I also hard coded the connection credentials in the python app, but for production I would use the python OS environment variables.
In the following code, I create the Database "Agario" which contains the Rooms and Players table.
Since the PlayerID and RoomID need to be unique, I use the Primary Key and Auto Increment keyword when creating the tables.
import mysql.connector
def createDB():
try:
global db
db = mysql.connector.connect(host="localhost", user="root", password="123")
except:
print("Database connection failed, exiting")
exit()
global dbCursor
dbCursor = db.cursor(buffered=True)
dbCursor.execute("DROP DATABASE Agario") # for testing, clears database on start
dbCursor.execute("CREATE DATABASE IF NOT EXISTS Agario")
dbCursor.execute("USE Agario")
dbCursor.execute(
"""
CREATE TABLE IF NOT EXISTS Players (
PlayerID int NOT NULL AUTO_INCREMENT,
Name VARCHAR(255) NOT NULL,
UNIQUE (Name),
PRIMARY KEY (PlayerID)
)
"""
)
dbCursor.execute(
"""
CREATE TABLE IF NOT EXISTS Rooms (
RoomID int NOT NULL AUTO_INCREMENT,
PRIMARY KEY (RoomID)
)
"""
)
db.commit()
We can insert a Player using the following code:
def createPlayer(Name):
query = """
INSERT INTO Players (Name)
VALUES (%s)
"""
try:
dbCursor.execute(query, (Name,))
db.commit()
return dbCursor.lastrowid
except:
return -1
API
Using the Flask and Socket.io libraries in python, I was able to quickly put together a working API and WebSocket connection between the server and client.
In hindsight, using just the WebSocket connection would have sufficed
for this game.
I used the API for all the menu items such as:
- Player creation
- Room creation
- Room join / leave
The code below is an example of how I created the
/players/create
endpoint for my API. I used the
Flask
blueprints
because I wanted the endpoints for players and rooms to be in separate
files and URLs. If the server receives data on the
/players/create
endpoint, the
createPlayer
function will be able to display the payload
that the client sent over. The client also gets returned a message along
with a HTTP response code.
# app.py
from flask import Flask
from flask_cors import CORS
from flask_restful import Api
from flask_socketio import SocketIO, join_room, leave_room
from players import players
from rooms import rooms
app = Flask(__name__)
app.register_blueprint(players)
app.register_blueprint(rooms)
CORS(app)
api = Api(app)
socketio = SocketIO(app, cors_allowed_origins="*")
if __name__ == "__main__":
socketio.run(app, debug=True, use_reloader=False)
# player.py
from flask import Blueprint, request
players = Blueprint("players", __name__)
@players.route("/players")
@players.route("/players/create", methods=["POST"])
def createPlayer():
print(request.json.get("playerName"))
return "Hello", 200
Web Socket
Since I wanted the server to be able to handle multiple rooms, I had to be able to group client web socket connections into rooms for each unique game. The Flask Socket.io library includes the implementation of rooms which allow the server to group client connections into separate broadcast domains.
Using the same app.py
from the API sections will host a web
socket server using flask.
The following code block will show an example of how clients get placed
and removed from rooms. When a player broadcasts
joinGame
to the global room, the server will take the
client payload, which includes the RoomID that was given to them from
the API. The on_join
function then calls
join_room
from the Flask library to place that client
connection in that RoomID. Finally the server broadcasts the update to
the clients room using socketio.emit
and the to=<room>
argument.
@socketio.on("joinGame")
def on_join(data):
print("Player is joining:")
print(data)
PlayerID = data["PlayerID"]
RoomID = data["RoomID"]
join_room(RoomID)
socketio.emit("sendMsgToClient", PlayerID + " has entered the room.", to=RoomID)
@socketio.on("leaveGame")
def on_leave(data):
print("Player is leaving:")
print(data)
PlayerID = data["PlayerID"]
RoomID = data["RoomID"]
leave_room(RoomID)
socketio.emit("sendMsgToClient", PlayerID + " has left the room.", to=RoomID)
Game Logic
Each game in session will have its own game loop. Each tick will update
player positions as well as game conditions, to make the game
consistent, each tick will have to take the same time as the others. To
achieve this, I keep track of the start time, and calculate the sleep
time needed for consistent ticks at the end of the game loop.
def gameLoop(game):
ticks = 0
secondCounter = 0
while game.Status:
startTimeSeconds = time.time()
ticks += 1
tick(game) # Updates everything
endTimeSeconds = time.time()
sleepTime = (1 / tickRate) - (endTimeSeconds - startTimeSeconds)
# print(sleepTime)
if sleepTime > 0:
time.sleep(sleepTime)
secondCounter = secondCounter + time.time() - startTimeSeconds
if secondCounter > 1:
print("tps: " + str(ticks))
secondCounter -= 1
ticks = 0
game.deltaTime = time.time() - startTimeSeconds
The updates done by the tick can be seen below.
def tick(game):
game.counter += 1
# stuff
# print("Tick")
# print("Current Players:")
# print(*game.Entities, sep='\n')
# print(len(game.Entities))
if len(game.Entities) == 0:
game.Status = False
print("Room: " + game.RoomID + " empty, ending game")
for entity in game.Entities:
entity.updatePos(game.deltaTime, mapSizeX, mapSizeY)
if game.counter == foodSpawnFrequency:
food = Entity(
-1,
True,
Vec2(
random.randrange(-int(mapSizeX), int(mapSizeX)),
random.randrange(-int(mapSizeY), int(mapSizeY)),
),
Vec2(0, 0),
Vec2(0, 0),
defaultFoodSize,
)
game.Entities.append(food)
game.counter = 0
# if (bruh):
# food = Entity(-1, True, False, 0, 1, 0, 0, defaultFoodSize)
# game.Entities.append(food)
# bruh = False
# Check Collisions, broadcast to players, players will know state of player, then remove dead
game.checkCollisons()
update = json.dumps([player.toDict() for player in game.Entities])
socketio.emit("sendUpdateToClient", update, to=game.RoomID)
game.removeEntities()
To expand on the updates each tick, we'll have to define two classes, the Entity and the Room. Since Entities have to be able to move around, we can model their movement using a forces.
Python doesn't seem to have a built-in vector class, so I created one
for convenience. This will greatly help with movement of Entities.
class Vec2:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"x: {self.x}, y: {self.y}"
def add(self, other):
v = Vec2(self.x, self.y)
return v.addi(other)
def addi(self, other):
self.x += other.x
self.y += other.y
return self
def sub(self, other):
v = Vec2(self.x, self.y)
return v.subi(other)
def subi(self, other):
self.x -= other.x
self.y -= other.y
return self
def mul(self, b):
v = Vec2(self.x, self.y)
return v.muli(b)
def muli(self, b):
self.x *= b
self.y *= b
return self
def div(self, b):
v = Vec2(self.x, self.y)
return v.divi(b)
def divi(self, b):
self.x /= b
self.y /= b
return self
def magsq(self):
return self.x**2 + self.y**2
def mag(self):
return math.sqrt(self.magsq())
def norm(self):
self.divi(self.mag())
The movement of the players mouse position, which is given by the client, will act as a force to move the player around the map. We limit the speed with "friction" which was cheaply done by exponentially decreasing the players velocity each tick. The impulse was added in when I was implementing the split mechanism, however it is not complete yet. The update movement method also keeps the player inside the map bounds by doing checks every tick.
class Entity:
def __init__(self, PlayerID, state, position, velocity, inputVec, size):
self.PlayerID = PlayerID
self.state = state
self.position = position
self.velocity = velocity
self.inputVec = inputVec
self.size = size # Diameter
def __str__(self):
return f"PlayerID: {self.PlayerID} state: {self.state} pos: {self.position} vel: {self.velocity} inVec: {self.inputVec} size: {self.size}"
def toDict(self):
return {
"PlayerID": self.PlayerID,
"state": self.state,
"xpos": self.position.x,
"ypos": self.position.y,
"size": self.size,
}
def applyForce(self, force, deltaTime):
self.velocity.addi(force.div(self.size).mul(deltaTime))
def applyImpulse(self, force):
self.velocity.addi(force.div(self.size))
def updatePos(self, deltaTime, mapSizeX, mapSizeY):
if self.state:
self.applyForce(self.inputVec.mul(5000), deltaTime)
self.velocity.muli(0.98)
self.position.addi(self.velocity.mul(deltaTime))
if self.position.x > mapSizeX:
self.position.x = mapSizeX
self.velocity.x = 0
if self.position.x < -mapSizeX:
self.position.x = -mapSizeX
self.velocity.x = 0
if self.position.y > mapSizeY:
self.position.y = mapSizeY
self.velocity.y = 0
if self.position.y < -mapSizeY:
self.position.y = -mapSizeY
self.velocity.y = 0
def updateSize(self, delta):
self.size += delta
The Room class will handle the interactions between players.
class Room:
def __init__(self, RoomID, Status, Entities):
self.RoomID = RoomID
self.Status = Status
self.Entities = Entities
def removeEntities(self):
for i in range(len(self.Entities) - 1, -1, -1):
if self.Entities[i].state == False:
print(
"Player: "
+ str(self.Entities[i].PlayerID)
+ " is dead, removing from player list"
)
self.Entities.pop(i)
def checkCollisons(self):
deaths = [-1] * len(self.Entities)
for i in range(0, len(self.Entities)):
for j in range(i + 1, len(self.Entities)):
# print("Loop check collisions")
if self.Entities[i].state and self.Entities[j].state:
e1Radius = self.Entities[i].size / 2
e2Radius = self.Entities[j].size / 2
radiiDistance = math.sqrt(
math.pow(self.Entities[i].xpos - self.Entities[j].xpos, 2)
+ math.pow(self.Entities[i].ypos - self.Entities[j].ypos, 2)
)
if radiiDistance <= 1e-9:
if e1Radius > e2Radius:
deaths[j] = i
else:
deaths[i] = j
continue
x = (
math.pow(e1Radius, 2)
- math.pow(e2Radius, 2)
+ math.pow(radiiDistance, 2)
) / (2 * radiiDistance)
# print("x value " + str(x))
y = math.pow(e1Radius, 2) - math.pow(x, 2)
if y > 0:
y = math.sqrt(y)
# print("y value " + str(y))
theta1 = 2 * math.asin(y / e1Radius)
# print("theta1 " + str(theta1))
area1 = (math.pow(e1Radius, 2) / 2) * (
theta1 - math.sin(theta1)
)
theta2 = 2 * math.asin(y / e2Radius)
area2 = (math.pow(e2Radius, 2) / 2) * (
theta2 - math.sin(theta2)
)
area = area1 + area2
# print("area 1 " + str(area1))
# print("area 2 " + str(area2))
# print("Total area " + str(area))
if e1Radius > e2Radius:
if area >= (math.pow(e2Radius, 2) * math.pi) / 2:
deaths[j] = i
else:
if area >= (math.pow(e1Radius, 2) * math.pi) / 2:
deaths[i] = j
else:
if radiiDistance < max(e1Radius, e2Radius):
if e1Radius > e2Radius:
deaths[j] = i
else:
deaths[i] = j
# print(deaths)
for i in range(0, len(deaths)):
if deaths[i] != -1:
self.Entities[deaths[i]].size = (
self.Entities[deaths[i]].size + self.Entities[i].size
)
for i in range(len(deaths) - 1, -1, -1):
if deaths[i] != -1:
print(str(self.Entities[i].PlayerID) + " has died, updating state")
self.Entities[i].state = False
print(self.Entities[i])
def __str__(self):
temp = f"RoomID: {self.RoomID} Status: {self.Status}"
for entity in self.Entities:
temp = temp + "\n" + str(entity)
return temp
Client Setup
The HTML and CSS are also included in the repository, Most of it was poorly put together.
API Calls
The server was designed to handle the creation of player and rooms. In
hindsight, I think that the API routes were not needed as the transfer
of this data could also be handled by the WebSocket. The JavaScript code
below sends the player name to the server using a fetch request
asynchronously. The server will send back a PlayerID upon success, which
causes the JS promise to resolve which leads to the global PlayerID
variable to be set. The rest of the requests act similarly and are of
the same structure.
const serverIP = "http://127.0.0.1:5000";
function createPlayer() {
PlayerName = document.getElementById("input_create_player").value;
clearInputs();
var myHeaders = new Headers();
myHeaders.append("Content-Type", "application/json");
var raw = JSON.stringify({
playerName: PlayerName,
});
var requestOptions = {
method: "POST",
headers: myHeaders,
body: raw,
redirect: "follow",
};
fetch(serverIP + "/players/create", requestOptions)
.then((response) => {
// console.log(response.status);
if (!response.ok) {
throw new Error("Player Creation Failed");
}
return response.text();
})
.then((result) => {
console.log("Created player with ID: " + result);
PlayerID = result;
document.getElementById("div_player").style.display = "none";
document.getElementById("div_menu").style.display = "flex";
})
.catch((error) => console.log("error", error));
}
WebSocket
The concept of rooms in Socket.io was explained earlier in the server section. Since the server
var socket = io.connect(serverIP);
socket.on("sendUpdateToClient", function (msg) {
data = JSON.parse(msg);
Players = data;
for (let i = 0; i < data.length; i++) {
if (data[i]["PlayerID"] == PlayerID) {
if (!data[i]["state"]) {
PlayerStatus = false;
}
}
}
});