Alzadi, My First Video Game
My game. I had planned this a long time ago but it took me a while to get the necessary skills to even start. But now that I have them, I was able to start this first iteration.
First steps
I always dreamed of a game where many people could come in and enjoy my ideas. This MMORPG idea was going to be the kick-off, combining my experience playing World of Warcraft, Mu Online and Tibia, to create a unique work. My first doubt: is it even possible? I researched different forums, news, talked to friends, and what always resonated with me was: to make a MMORPG you both need a lot of money and experience. This made me think of giving up, I didn't have either of those two resources.
Encouraging Reddit comment
I thought about starting by developing an offline game, and once ready, enable the online gameplay. But in the process (and after reading some posts), I understood that this was more difficult and required more time than starting with online mode from the beginning. The game has its roots too defined by the game mode, which makes it quite difficult to migrate (there I understood why many of my favorite games were not going to make them online).
Decided to go on
Then I said to myself that I must do it online from the start. Initially, I found a method to communicate an Unreal Engine client with a web server written in JavaScript using HTTP requests and JSON packages (I learned to use the basics of Node.js in W3Schools, I loved it). It worked, but the problem was the impossibility to start communications from the web server, so each client had to notify when there was a change in its session, and other clients only found out about their peers' changes when they made their requests to the web server. I discarded it because of the risk of latency and the little authority that the game server would have (and later I realized that the client/game server relationships would be chaotic if they had to always go through a web server without a native understanding of the Unreal Engine functionalities).
Network infrastructure
I continued researching and discovered that my connection problem could be solved with Websockets. A marvel: they allowed to connect a web server directly with the clients and share all the information they wanted without creating new connections, which let me know exactly which clients were requesting and receiving. Besides, both Unreal Engine and Node.js had the necessary libraries to handle them.
After a few days of brainstorming how the infrastructure logic was going to be, I remembered the model I used in my Tibia OTServers: the client connected to a game server, and used a web server to create/delete accounts that were hosted in a database (which also contained all the players' information). It made sense to me right away. First I created the webserver in Node.js (I read that it was the fastest for this task), learned to use MongoDB in W3Schools to manage the data (recommended, much more intuitive than SQL, and allows to work without a defined structure, which is useful to store data as different as items in a backpack or available magics for a certain vocation), and used Docker containers to create an executable that allowed me to start with a single click the web server and the database. Then using Unreal Engine I created a separate client and game server. The dynamics between the four components worked as follows: the player would open the client and connect it to the web server, which would validate the player's credentials against the database. Once the client was authorized to use the account, a new object was invoked on the game server (the web server was in charge of sending that instruction) and at the same time a proxy of the character was created on the player's own client and on other connected players' clients. If the player moved, this action was sent to the web server through the websocket opened during the initial connection, which then notified the game server through its own websocket. The game server moved the character and sent a signal back to the web server, which was sent to all connected clients and finally moved the character in the different screens. At the same time the web server saved the new position in the database to have it in case the connection was lost.
Initial network diagram
In that scheme, it would be necessary to model each of the client/game server interactions using the web server, which did not seem sustainable to me. In addition, saving each player interaction in the database was crazy, it generated so much latency that I had to restrict the number of inputs a player could give each second, which ended up giving a very unpleasant feeling of discrete movement. The solution made sense to me, but it wasn't scalable. I needed something different.
Unreal Engine has a networking system that I didn't want to mess with at first because of its complexity, but now it was one of the most realistic options to use. Hands on, I learned to use it in a few days using the UE4 Network Compendium by Cedric 'eXi' Neukirchen (replication, authority, ownership, and many other complicated networking concepts) and implemented it using a similar model client/game server/web server/database, but with a different interaction between them. Now the clients could communicate directly with the game server without having to go through the web server for each interaction. I no longer had to manually create proxy characters on the client, because Unreal Engine allows to program the client and server in the same code using conditions that apply to each case, and then have a different effect on client and game server. A marvel, it saved me the work of writing 3 different codes (client, game server and web server) to only 1 (client + game server), although I had headaches understanding how to use the new system. The web server and database were used to store recurring player information and to let new player login, but were no longer needed for every interaction such as player movement.
Current network diagram
Once the networking system was achieved, I could start coding everything I had in my head: connection/disconnection, create char, login/logout, movement/target/follow, hits/health, experience/level, items/inventory/set, and monsters. I'll go one by one.
Connection/disconnection
Initially, the client opens an empty map that only allows you to enter an IP address to connect to the game server.
Connection test
Once the connection process between the client and the game server is initiated, the game server (for each client) generates a new WebSocket with the web server, which is completely isolated from the client's control. The disconnection process is the same but in reverse, first, the web socket is closed and then the client returns to the empty map with the possibility to connect again. The same happens when there is any error either in the game server or the web server, in both cases, the connection is broken if one of the two fails.
Game server method
void AAlzadiPlayerController::ConnectToServer()
{
// Test if module is active or not
if (!FModuleManager::Get().IsModuleLoaded("WebSockets"))
{
FModuleManager::Get().LoadModule("WebSockets");
}
// Connect to web server
WebSocket = FWebSocketsModule::Get().CreateWebSocket("ws://127.0.0.1:8080");
// Message when successfully connected to server
WebSocket->OnConnected().AddLambda([this]()
{
GEngine->AddOnScreenDebugMessage(-1, 15.f, FColor::Green, "Successfully connected");
});
// Message when initial connection failed
WebSocket->OnConnectionError().AddLambda([this](const FString& Error)
{
GEngine->AddOnScreenDebugMessage(-1, 15.f, FColor::Red, Error);
});
// Message when connection is closed, due to error or not
WebSocket->OnClosed().AddLambda([this](int32 StatusCode, const FString& Reason, bool bWasClean)
{
GEngine->AddOnScreenDebugMessage(-1, 15.f, bWasClean ? FColor::Green : FColor::Red,
"Connection closed: " + Reason);
});
// Action when data is received
WebSocket->OnMessage().AddLambda([this](const FString& MessageString)
{
GEngine->AddOnScreenDebugMessage(-1, 15.f, FColor::Cyan, "Received message: " + MessageString);
// FData is a struct especially designed to store everything that travels from game server to webserver or backwards
FData Data;
FJsonObjectConverter::JsonObjectStringToUStruct(MessageString, &Data, 0, 0);
});
// Action when data is sent
WebSocket->OnMessageSent().AddLambda([this](const FString& MessageString)
{
GEngine->AddOnScreenDebugMessage(-1, 15.f, FColor::Yellow, "Sent message: " + MessageString);
});
// Connect to web server
WebSocket->Connect();
}
Web server method
const { randomUUID } = require("crypto");
const { WebSocketServer } = require("ws");
// SERVER FUNCTIONALITY
let connectedClients = new Map(); // keep track of connected clients
let connectedAccounts = new Map(); // keep track of logged users
let webSocketServer = new WebSocketServer({ port: 8080 }); // initiate a new server that listens on port 8080
// set up event handlers and do other things upon a client connecting to the server
webSocketServer.on("connection", (webSocket) => {
let connectedClientId = randomUUID(); // store the websocket identity
connectedClients.set(webSocket, connectedClientId);
console.log(`Client connected with id: ${connectedClientId}`);
// behavior when new message is received
webSocket.on("message", (dataReceived) => {
console.log(`Received: ${dataReceived}`);
let objectReceived = JSON.parse(dataReceived); // get JSON object from string
// all what happens with the new information received was omited for this example
});
// stop tracking the client upon that client closed the connection
webSocket.on("close", () => {
console.log(`Client ${connectedClients.get(webSocket)} has disconnected`);
connectedClients.delete(webSocket);
if (connectedAccounts.has(webSocket)) {
console.log(`${connectedAccounts.get(webSocket)} has disconnected`);
connectedAccounts.delete(webSocket);
}
});
});
Create character
Once the client connects to the game server, if you do not have an account already created, the client allows you to enter a new username and password to store in the database.
Create character test
The client will ask for a username and password, which will then be delivered to the game server through an RPC (Remote Procedure Call, one of Unreal Engine's replication system that allows to call functions from client/game server and have them executed in the other one). Once the create button is pressed, a Json is created with the information provided by the client (including health and spawn point), and it is sent to the web server.
Game server method when create button is clicked
void AAlzadiPlayerController::CharacterCreate_Implementation(const FString& Account, const FString& Password)
{
FData Data;
Data.Topic = "characterCreate";
Data.Content.AlzadiAccount.Account = Account;
Data.Content.AlzadiAccount.Password = Password;
Data.Content.AlzadiCharacter = AlzadiGameMode->AlzadiDataAsset->NewPlayerGameInfo;
// name
Data.Content.AlzadiCharacter.Name = FName(*Account);
// Initial position
Data.Content.AlzadiCharacter.Location = Data.Content.AlzadiCharacter.SpawnPoint;
SendMessage(Data);
}
Game server method to send package
void AAlzadiPlayerController::SendMessage(FData Data)
{
if (WebSocket->IsConnected())
{
FString MessageString;
FJsonObjectConverter::UStructToJsonObjectString(Data, MessageString, 0, 0);
WebSocket->Send(MessageString);
}
}
The web server receives the JSON package and executes the following operations to respond to the game server (which then notifies the client) if there are problems or not: check if the account does not exist, if it is long enough and if the password is long enough. In case all of these are fulfilled, the web server creates the new user by applying SHA512 encryption to the password, and notifies the game server that it is ready.
Web server method for new character creation
async function characterCreate(webSocket, objectReceived) {
// check if account exists
let doesAccountExist = false;
let promise = new Promise((resolve, reject) => {
checkIfAccountDoesNotExistInDataBase(resolve, reject, objectReceived); // function explained below
});
try {
await promise;
} catch (error) {
doesAccountExist = true;
broadcastToGameServer(webSocket, "characterCreateError", error, null); // function explained below
}
if (!doesAccountExist) {
// check if account is 8 digit long
let doesAccountIsLongEnough = false;
if (objectReceived.alzadiAccount.account.length >= 1) {
doesAccountIsLongEnough = true;
} else {
broadcastToGameServer(
webSocket,
"characterCreateError",
"Account must be 8 character long",
null
);
}
if (doesAccountIsLongEnough) {
// check if password is 8 digit long
let doesPasswordIsLongEnough = false;
if (objectReceived.alzadiAccount.password.length >= 1) {
doesPasswordIsLongEnough = true;
} else {
broadcastToGameServer(
webSocket,
"characterCreateError",
"Password must be 8 character long",
null
);
}
if (doesPasswordIsLongEnough) {
// create character, hash password and give location
let salt = "alzadi";
let hash = crypto
.pbkdf2Sync(
objectReceived.alzadiAccount.password,
salt,
1000,
64,
"sha512"
)
.toString("hex");
let objectToInsert = new NewCharacter(
hash,
objectReceived
).convertToObject();
let promise = new Promise((resolve, reject) => {
createNewCharacterInDataBase(resolve, reject, objectToInsert);
});
try {
let result = await promise;
broadcastToGameServer(
webSocket,
"characterCreateSuccess",
result,
null
);
} catch (error) {
broadcastToGameServer(webSocket, "characterCreateError", error, null);
}
}
}
}
}
Web server method to check if account exists
// Search if account does not exist
function checkIfAccountDoesNotExistInDataBase(resolve, reject, objectToFind) {
MongoClient.connect(dataBaseUrl, (error, dataBase) => {
if (error) {
reject(error);
} else if (dataBase === null) {
reject("Data base does not exist");
} else {
let dataBaseObject = dataBase.db(dataBaseName);
let query = {
"public.alzadiAccount.account": objectToFind.alzadiAccount.account,
};
dataBaseObject
.collection(dataBaseCollection)
.findOne(query, (error, result) => {
dataBase.close();
if (error) {
reject(error);
} else if (result === null || result === undefined) {
resolve("Account does not exist");
} else {
reject("Account already exists");
}
});
}
});
}
Web server method to send package
function broadcastToGameServer(webSocket, topic, message, objectToSend) {
let objetToBroadcast = {
topic: topic,
message: message,
content: objectToSend,
};
console.log(`Sent: ${JSON.stringify(objetToBroadcast)}`);
webSocket.send(JSON.stringify(objetToBroadcast));
}
Web server method to create a new character
// Create new character
function createNewCharacterInDataBase(resolve, reject, objectToInsert) {
MongoClient.connect(dataBaseUrl, (error, dataBase) => {
if (error) {
reject(error);
} else if (dataBase === null) {
reject("Data base does not exist");
} else {
let dataBaseObject = dataBase.db(dataBaseName);
dataBaseObject
.collection(dataBaseCollection)
.insertOne(objectToInsert, (error, result) => {
dataBase.close();
if (error) {
reject(error);
} else if (result === null) {
} else {
resolve("Character successfully created");
}
});
}
});
}
Login/Logout
The login system uses the same prompt as the account creation, asking for a username and password. The logout generates the same effect as a disconnection, leading to the empty map explained in that section.
Login/logout test
When the login button is clicked, a series of processes are chained that allow the entry of a character to the game server. First, the client sends the account credentials to the game server, and then the game server sends them to the web server for matching.
Game server method when player clicks login
void AAlzadiPlayerController::CharacterLogin_Implementation(const FString& Account, const FString& Password)
{
FData Data;
Data.Topic = "characterLogin";
Data.Content.AlzadiAccount.Account = Account;
Data.Content.AlzadiAccount.Password = Password;
SendMessage(Data);
}
The web server, as in the case of account creation, performs a series of processes in order to know what to do: checks if the account does exist, checks if the password is correct and if the account is logged out. If everything is in order, the web server returns a JSON package to the game server with all the character information (name, location, skills, experience, health, among others).
Web server method to check login
async function characterLogin(webSocket, objectToFind) {
//check if account exists
let doesAccountExist = false;
let promise = new Promise((resolve, reject) => {
checkIfAccountDoesExistInDataBase(resolve, reject, objectToFind); // function explained below
});
try {
await promise;
doesAccountExist = true;
} catch (error) {
broadcastToGameServer(
webSocket,
"characterLoginError",
error,
objectToFind
);
}
if (doesAccountExist) {
//check if password is correct
let isPasswordCorrect = false;
let promise = new Promise((resolve, reject) => {
checkIfPasswordIsCorrect(resolve, reject, objectToFind);
});
try {
await promise;
isPasswordCorrect = true;
} catch (error) {
broadcastToGameServer(
webSocket,
"characterLoginError",
error,
objectToFind
);
}
if (isPasswordCorrect) {
//check if account is already loged in
let isLogedIn = false;
connectedAccounts.forEach((value) => {
if (objectToFind.alzadiAccount.account === value) {
isLogedIn = true;
broadcastToGameServer(
webSocket,
"characterLoginError",
"Account already loged in",
objectToFind
);
}
});
if (!isLogedIn) {
// Create a promise that search for character info
let promise = new Promise((resolve, reject) => {
findObjectByAccountInDataBase(resolve, reject, objectToFind);
});
try {
let result = await promise; // waits until new connection data is fetched
// returns location to new connected character
broadcastToGameServer(
webSocket,
"characterLoginSuccess",
null,
result
);
// Important: here Character is created
connectedAccounts.set(webSocket, objectToFind.alzadiAccount.account);
console.log(`${connectedAccounts.get(webSocket)} logged in`);
} catch (error) {
console.log(error);
broadcastToGameServer(webSocket, "characterLoginError", error, null);
}
}
}
}
}
Web server method to check if account exists
// Search if account does exist
function checkIfAccountDoesExistInDataBase(resolve, reject, objectToFind) {
MongoClient.connect(dataBaseUrl, (error, dataBase) => {
if (error) {
reject(error);
} else if (dataBase === null) {
reject("Data base does not exist");
} else {
let dataBaseObject = dataBase.db(dataBaseName);
let query = {
"public.alzadiAccount.account": objectToFind.alzadiAccount.account,
};
dataBaseObject
.collection(dataBaseCollection)
.findOne(query, (error, result) => {
dataBase.close();
if (error) {
reject(error);
} else if (result === null || result === undefined) {
reject("Account does not exist");
} else {
resolve("Account already exists");
}
});
}
});
}
Web server method to check if password is correct
// Check if password is correct
function checkIfPasswordIsCorrect(resolve, reject, objectToFind) {
MongoClient.connect(dataBaseUrl, (error, dataBase) => {
if (error) {
reject(error);
} else if (dataBase === null) {
reject("Data base does not exist");
} else {
let dataBaseObject = dataBase.db(dataBaseName);
let query = {
"public.alzadiAccount.account": objectToFind.alzadiAccount.account,
};
dataBaseObject
.collection(dataBaseCollection)
.findOne(query, (error, result) => {
dataBase.close();
let salt = "alzadi";
let hash = crypto
.pbkdf2Sync(
objectToFind.alzadiAccount.password,
salt,
1000,
64,
"sha512"
)
.toString("hex");
if (error) {
reject(error);
} else if (result.private.hash === hash) {
resolve("Password is correct");
} else {
reject("Incorrect password");
}
});
}
});
}
Web server method to search account in database
// Find objects by account
function findObjectByAccountInDataBase(resolve, reject, objectToFind) {
MongoClient.connect(dataBaseUrl, (error, dataBase) => {
if (error) {
reject(error);
} else if (dataBase === null) {
reject("Data base does not exist");
} else {
let dataBaseObject = dataBase.db(dataBaseName);
let query = {
"public.alzadiAccount.account": objectToFind.alzadiAccount.account,
};
dataBaseObject
.collection(dataBaseCollection)
.findOne(query, (error, result) => {
dataBase.close();
if (error) {
reject(error);
} else if (result === null) {
reject("Account does not exist");
} else {
resolve(result.public);
}
});
}
});
}
Once the JSON package with the character information is received, the game server creates a new object on the map with all the data received, and automatically communicates it to all clients using the Unreal Engine replication system.
Game server method to login
void AAlzadiPlayerController::CharacterLoginSuccessServer(FData Data)
{
// all information stored in the Json translates to a FData struct and then to a character object in game, which has different components to store each characteristic
BaseCharacterRef->SetCharacterType(Data.Content.AlzadiCharacter.CharacterType);
BaseCharacterRef->SetCharacterName(Data.Content.AlzadiCharacter.Name);
BaseCharacterRef->SetSpawnPoint(Data.Content.AlzadiCharacter.SpawnPoint);
// Set experience
BaseCharacterRef->ExperienceComponent->SetExperience(Data.Content.AlzadiCharacter.Experience);
// Set Skills
BaseCharacterRef->SkillComponent->SetSkill(Data.Content.AlzadiCharacter.Skill);
// Set items
BaseCharacterRef->EquipmentComponent->SetEquipedFromData(Data.Content.AlzadiCharacter.Equipment.Set);
BaseCharacterRef->EquipmentComponent->SetInventoryFromData(Data.Content.AlzadiCharacter.Equipment.Inventory);
// Set MaxHealth based on level, vocation and items, and currentHealth based on web server
BaseCharacterRef->HealthComponent->SetMaxHealth(BaseCharacterRef->ExperienceComponent->GetLevel(), 0.f);
BaseCharacterRef->HealthComponent->SetHealth(Data.Content.AlzadiCharacter.Health);
// put character in place
BaseCharacterRef->SetActorLocation(Data.Content.AlzadiCharacter.Location.ToFVector());
// and then the game server let the client know that is loged in
CharacterLoginSuccessClient(Data);
}
When pressing the logout button, the game server sends a JSON package with all the character information to the web server.
Game server method to logout
void AAlzadiPlayerController::CharacterLogout_Implementation()
{
FData Data = PackCharacterInfo();
Data.Topic = "characterLogout";
SendMessage(Data);
}
Game server method to save player info
FData AAlzadiPlayerController::PackCharacterInfo()
{
FData Data;
Data.Content.AlzadiCharacter.CharacterType = BaseCharacterRef->GetCharacterType();
Data.Content.AlzadiCharacter.Name = BaseCharacterRef->GetCharacterName();
Data.Content.AlzadiCharacter.SpawnPoint = BaseCharacterRef->GetSpawnPoint();
FLocation Location;
Location.FromFVector(BaseCharacterRef->GetActorLocation());
Data.Content.AlzadiCharacter.Location = Location;
// Current Experience
Data.Content.AlzadiCharacter.Experience = BaseCharacterRef->ExperienceComponent->GetExperience();
// Current Health
Data.Content.AlzadiCharacter.Health = BaseCharacterRef->HealthComponent->GetHealth();
// Current Skills
Data.Content.AlzadiCharacter.Skill = BaseCharacterRef->SkillComponent->GetSkill();
// Current Equipment
Data.Content.AlzadiCharacter.Equipment.Set = BaseCharacterRef->EquipmentComponent->GetEquipedData();
Data.Content.AlzadiCharacter.Equipment.Inventory = BaseCharacterRef->EquipmentComponent->GetInventoryData();
return Data;
}
The web server updates the database upon receiving the information and notifies the game server that the player can now be removed from the game world.
Web server method to logout
async function characterLogout(webSocket, objectReceived) {
objectReceived.alzadiAccount.account = connectedAccounts.get(webSocket);
let promise = new Promise((resolve, reject) => {
updateObjectInDataBase(resolve, reject, objectReceived);
});
try {
//wait until saved
await promise;
// delete info in memory
if (connectedAccounts.has(webSocket)) {
connectedAccounts.delete(webSocket);
}
// let user know that character is saved, so can proceed to logout
broadcastToGameServer(webSocket, "characterLogoutSuccess", null, null);
console.log(
`${objectReceived.alzadiAccount.account} successfully logged out`
);
} catch (error) {
broadcastToGameServer(webSocket, "characterLogoutError", null, null);
console.log(error);
}
}
Web server method to update player in database
function updateObjectInDataBase(resolve, reject, objectReceived) {
MongoClient.connect(dataBaseUrl, (error, dataBase) => {
if (error) reject(error);
let dataBaseObject = dataBase.db(dataBaseName);
let query = {
"public.alzadiAccount.account": objectReceived.alzadiAccount.account,
};
let paramsToUpdate = { $set: { public: objectReceived } };
dataBaseObject
.collection(dataBaseCollection)
.updateOne(query, paramsToUpdate, (error, result) => {
dataBase.close();
if (error) {
reject(error);
} else if (result === null) {
reject("Account does not exist");
} else {
resolve(result.public);
}
});
});
}
The logout is linked to the disconnection to take advantage of the lines of code that already allow destroying the object created in the game server so that it does not continue to exist when the account is disconnected.
Movement/target/follow
The idea is to restrict the character to move only vertically, horizontally, and diagonally.
Movement test
Game server method to move vertically
void ABaseCharacter::MoveForward(float Value)
{
if ((Controller != nullptr) && (Value != 0.0f))
{
bAIFollowActivated = false;
// find out which way is forward
const FRotator Rotation = Controller->GetControlRotation();
const FRotator YawRotation(0, Rotation.Yaw, 0);
// get forward vector
const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
AddMovementInput(Direction, Value);
}
}
Game server method to move horizontally
void ABaseCharacter::MoveRight(float Value)
{
if ((Controller != nullptr) && (Value != 0.0f))
{
bAIFollowActivated = false;
// find out which way is right
const FRotator Rotation = Controller->GetControlRotation();
const FRotator YawRotation(0, Rotation.Yaw, 0);
// get right vector
const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
// add movement in that direction
AddMovementInput(Direction, Value);
}
}
Currently the game is configured so that the character can attack a target.
Target test
Game server method to hover over other characters
void AAlzadiPlayerController::MouseHoverClient()
{
// Get hovered actor
FHitResult HitResult;
GetHitResultUnderCursor(ECC_Pawn, false, HitResult);
// Has to be a base character
if (Cast<ABaseCharacter>(HitResult.GetActor()))
{
// Clean hovered client
if (HoveredTarget)
{
// only if it is not locked target
if (HoveredTarget != BaseCharacterRef->LockedTarget)
{
if (HoveredTarget->PlaneComponent)
{
if (HoveredTarget->PlaneComponent->GetStaticMesh())
{
HoveredTarget->PlaneComponent->SetMaterial(0, NoTargetMaterial);
HoveredTarget = nullptr;
}
}
}
}
// Cannot be itself, cannot be locked one
if (Cast<ABaseCharacter>(HitResult.GetActor()) != BaseCharacterRef && Cast<
ABaseCharacter>(HitResult.GetActor()) != BaseCharacterRef->LockedTarget)
{
HoveredTarget = Cast<ABaseCharacter>(HitResult.GetActor());
if (HoveredTarget)
{
if (HoveredTarget->PlaneComponent)
{
if (HoveredTarget->PlaneComponent->GetStaticMesh())
{
HoveredTarget->PlaneComponent->SetMaterial(0, HoveredTargetMaterial);
}
}
}
}
}
else
{
// Removes current hovered target if not hovered
if (HoveredTarget)
{
// only if it is not locked
if (HoveredTarget != BaseCharacterRef->LockedTarget)
{
if (HoveredTarget->PlaneComponent)
{
if (HoveredTarget->PlaneComponent->GetStaticMesh())
{
HoveredTarget->PlaneComponent->SetMaterial(0, NoTargetMaterial);
HoveredTarget = nullptr;
}
}
}
}
}
}
When attacking, the character immediately starts chasing its opponent. This was quite tricky to code because the MoveToActor function is not enabled when using a PlayerController, so I had to implement an AIController in a second layer, which replicates exactly what the PlayerController does and they alternate to make the user believe that it is the same object (but in reality every time you attack, the original character is hidden and appears one exactly the same but controlled by an AI).
Follow test
Game server method to follow a moving target
void AAlzadiProxyCharacterAIController::TargetLoop()
{
if (GetLocalRole() == ROLE_Authority)
{
if (AlzadiProxyCharacter->BaseCharacterRef->bAIFollowActivated)
{
if (!AlzadiProxyCharacter->BaseCharacterRef->LockedTarget)
{
// stop moving
StopMovement();
AlzadiProxyCharacter->BaseCharacterRef->bIsVisible = true;
}
else
{
// start moving
MoveToActor(Cast<AActor>(AlzadiProxyCharacter->BaseCharacterRef->LockedTarget));
AlzadiProxyCharacter->BaseCharacterRef->bIsVisible = false;
}
}
}
}
Attack/health
Every time you are attacked, hit points are deducted on the game server, and this is displayed on all connected clients.
Attack test
In this case, it is very important to clarify that health (as well as other relevant components such as experience and level) is controlled ONLY by the game server. This means that the function to subtract health points is executed by the game server, even though the client is the one who initiates the attack. This is achieved by using another Unreal Engine replication system, the RepNotify, which allows one to notice all clients when a game server variable is affected.
Experiencie/level
The experience system defines what level the character has, which will define his health, max health, attack power and speed.
Experience test
Items/inventory/equipment
Items are an essential component of RPGs, so I couldn't go through this first iteration of my game without learning how this works (boy, did it cost me).
Once a user logs in, one of the most important components coming from the web server as a Json is the equipment and inventory. As there are several possible drag and drop options (to an empty slot in the inventory, to an occupied slot in the inventory, or to the set), each of the possibilities had to be coded, even those with cumulative items.
Items test
Game server method to move items
void UEquipmentComponent::SetEquipedItemFromDragAndDrop_Implementation(UItemBase* NewItem, UItemBase* OriginalItem)
{
if (bDragAndDropEnabled)
{
bDragAndDropEnabled = false;
int32 OriginalItemPosition = OriginalItem->EquipedPosition;
int32 NewItemPosition = NewItem->EquipedPosition;
float OriginalItemQuantity = OriginalItem->Quantity;
float NewItemQuantity = NewItem->Quantity;
float NewItemStackLimit = NewItem->StackLimit;
// in case item moves from equipment to equipment (deberia salir pronto)
if (NewItem->bIsEquiped && OriginalItem->bIsEquiped)
{
// in case is moved to inventory panel
if (NewItem == OriginalItem)
{
// update equiped
Equiped[NewItemPosition] = NewObject<UItemBase>();
Equiped[NewItemPosition]->bIsEquiped = true;
Equiped[NewItemPosition]->EquipedPosition = NewItemPosition;
Equiped[NewItemPosition]->Id = BaseCharacter->AlzadiGameModeRef->AlzadiDataAsset->BlankItemId;
Equiped[NewItemPosition]->Quantity = 1;
Equiped[NewItemPosition]->SetItemFromData(
*BaseCharacter->AlzadiGameModeRef->AlzadiDataAsset->ItemDataTable->FindRow<FItemInfo>(
Equiped[NewItemPosition]->Id,TEXT("lol")));
Equiped[NewItemPosition]->ItemInfo.ItemType = NewItem->ItemInfo.ItemType;
// update inventory
Inventory.Add(NewItem);
NewItem->bIsEquiped = false;
Inventory[Inventory.Num() - 1]->EquipedPosition = Inventory.Num() - 1;
}
else
{
// behavior for same id and stackable items
if (NewItem->Id == OriginalItem->Id && NewItem->ItemInfo.bIsStackable)
{
// in case both items sum up more than limit
if (NewItemQuantity + OriginalItemQuantity > NewItemStackLimit)
{
// swap both items to get replication, first change properties, then change item itself
OriginalItem->Quantity = NewItemQuantity + OriginalItemQuantity -
NewItemStackLimit;
OriginalItem->EquipedPosition = NewItemPosition;
NewItem->Quantity = NewItemStackLimit;
NewItem->EquipedPosition = OriginalItemPosition;
Equiped[NewItemPosition] = OriginalItem;
Equiped[OriginalItemPosition] = NewItem;
}
// if no remanent, item should be replaced with empty space
else
{
// add both quantities
Equiped[OriginalItemPosition]->Quantity = NewItemQuantity + OriginalItemQuantity;
// delete item
Equiped[NewItemPosition] = NewObject<UItemBase>();
Equiped[NewItemPosition]->bIsEquiped = true;
Equiped[NewItemPosition]->EquipedPosition = NewItemPosition;
Equiped[NewItemPosition]->Id = BaseCharacter->AlzadiGameModeRef->AlzadiDataAsset->BlankItemId;
Equiped[NewItemPosition]->Quantity = 1;
Equiped[NewItemPosition]->SetItemFromData(
*BaseCharacter->AlzadiGameModeRef->AlzadiDataAsset->ItemDataTable->FindRow<FItemInfo>(
Equiped[NewItemPosition]->Id,TEXT("lol")));
}
}
else
{
if (NewItem->ItemInfo.ItemType == OriginalItem->ItemInfo.ItemType)
{
// update equiped
Equiped[NewItem->EquipedPosition] = OriginalItem;
Equiped[OriginalItem->EquipedPosition] = NewItem;
NewItem->EquipedPosition = OriginalItemPosition;
OriginalItem->EquipedPosition = NewItemPosition;
}
}
}
}
// in case the new item comes from inventory to equipment
else if (!NewItem->bIsEquiped && OriginalItem->bIsEquiped)
{
// behavior for same id and stackable items
if (NewItem->Id == OriginalItem->Id && NewItem->ItemInfo.bIsStackable)
{
// in case both items sum up more than limit
if (NewItemQuantity + OriginalItemQuantity > NewItemStackLimit)
{
// remanent stays at new item position
OriginalItem->Quantity = NewItemQuantity + OriginalItemQuantity - NewItemStackLimit;
OriginalItem->EquipedPosition = NewItemPosition;
OriginalItem->bIsEquiped = false;
// add maximum quantity to original item position
NewItem->Quantity = NewItemStackLimit;
NewItem->EquipedPosition = OriginalItemPosition;
NewItem->bIsEquiped = true;
Equiped[OriginalItemPosition] = NewItem;
Inventory[NewItemPosition] = OriginalItem;
}
// if no remanent, item should be replaced with empty space
else
{
// add both quantities
Equiped[OriginalItemPosition]->Quantity = NewItemQuantity + OriginalItemQuantity;
// delete item
Inventory.RemoveAt(NewItemPosition);
for (int32 Index = NewItemPosition; Index < Inventory.Num(); ++Index)
{
Inventory[Index]->EquipedPosition = Index;
}
}
}
else
{
if (NewItem->ItemInfo.ItemType == OriginalItem->ItemInfo.ItemType)
{
// update equipped
Equiped[OriginalItemPosition] = NewItem;
NewItem->EquipedPosition = OriginalItemPosition;
NewItem->bIsEquiped = true;
OriginalItem->bIsEquiped = false;
// update inventory
Inventory.RemoveAt(NewItemPosition);
if (OriginalItem->Id != BaseCharacter->AlzadiGameModeRef->AlzadiDataAsset->BlankItemId)
{
Inventory.Add(OriginalItem);
}
for (int32 Index = NewItemPosition; Index < Inventory.Num(); ++Index)
{
Inventory[Index]->EquipedPosition = Index;
}
}
}
}
// in case item comes from equipment to inventory
else if (NewItem->bIsEquiped && !OriginalItem->bIsEquiped)
{
// behavior for same id and stackable items
if (NewItem->Id == OriginalItem->Id && NewItem->ItemInfo.bIsStackable)
{
// in case both items sum up more than limit
if (NewItemQuantity + OriginalItemQuantity > NewItemStackLimit)
{
// remanent stays at new item position
OriginalItem->Quantity = NewItemQuantity + OriginalItemQuantity - NewItemStackLimit;
OriginalItem->EquipedPosition = NewItemPosition;
OriginalItem->bIsEquiped = false;
// add maximum quantity to original item position
NewItem->Quantity = NewItemStackLimit;
NewItem->EquipedPosition = OriginalItemPosition;
NewItem->bIsEquiped = false;
Inventory[OriginalItemPosition] = NewItem;
Inventory.Add(OriginalItem);
Inventory[Inventory.Num() - 1]->EquipedPosition = Inventory.Num() - 1;
// delete item
Equiped[NewItemPosition] = NewObject<UItemBase>();
Equiped[NewItemPosition]->bIsEquiped = true;
Equiped[NewItemPosition]->EquipedPosition = NewItemPosition;
Equiped[NewItemPosition]->Id = BaseCharacter->AlzadiGameModeRef->AlzadiDataAsset->BlankItemId;
Equiped[NewItemPosition]->Quantity = 1;
Equiped[NewItemPosition]->SetItemFromData(*
BaseCharacter->AlzadiGameModeRef->AlzadiDataAsset->ItemDataTable->FindRow<FItemInfo>(
Equiped[NewItemPosition]->Id,TEXT("lol")));
}
// if no remanent, item should be replaced with empty space
else
{
// add both quantities
Inventory[OriginalItemPosition]->Quantity = NewItemQuantity + OriginalItemQuantity;
// delete item
Equiped[NewItemPosition] = NewObject<UItemBase>();
Equiped[NewItemPosition]->bIsEquiped = true;
Equiped[NewItemPosition]->EquipedPosition = NewItemPosition;
Equiped[NewItemPosition]->Id = BaseCharacter->AlzadiGameModeRef->AlzadiDataAsset->BlankItemId;
Equiped[NewItemPosition]->Quantity = 1;
Equiped[NewItemPosition]->SetItemFromData(*
BaseCharacter->AlzadiGameModeRef->AlzadiDataAsset->ItemDataTable->FindRow<FItemInfo>(
Equiped[NewItemPosition]->Id,TEXT("lol")));
}
}
else
{
// update equiped
Equiped[NewItemPosition] = NewObject<UItemBase>();
Equiped[NewItemPosition]->bIsEquiped = true;
Equiped[NewItemPosition]->EquipedPosition = NewItemPosition;
Equiped[NewItemPosition]->Id = BaseCharacter->AlzadiGameModeRef->AlzadiDataAsset->BlankItemId;
Equiped[NewItemPosition]->Quantity = 1;
Equiped[NewItemPosition]->SetItemFromData(*
BaseCharacter->AlzadiGameModeRef->AlzadiDataAsset->ItemDataTable->FindRow<FItemInfo>(
Equiped[NewItemPosition]->Id,TEXT("lol")));
Equiped[NewItemPosition]->ItemInfo.ItemType = NewItem->ItemInfo.ItemType;
// update inventory
Inventory.Add(NewItem);
NewItem->bIsEquiped = false;
Inventory[Inventory.Num() - 1]->EquipedPosition = Inventory.Num() - 1;
}
}
// in case item moves from inventory to inventory
else if (!NewItem->bIsEquiped && !OriginalItem->bIsEquiped)
{
// in case is moved to inventory panel
if (NewItem == OriginalItem)
{
// update inventory
Inventory.RemoveAt(NewItemPosition);
Inventory.Add(NewItem);
for (int32 Index = NewItemPosition; Index < Inventory.Num(); ++Index)
{
Inventory[Index]->EquipedPosition = Index;
}
}
else
{
// behavior for same id and stackable items
if (NewItem->Id == OriginalItem->Id && NewItem->ItemInfo.bIsStackable)
{
// in case both items sum up more than limit
if (NewItemQuantity + OriginalItemQuantity > NewItemStackLimit)
{
// remanent stays at new item position
OriginalItem->Quantity = NewItemQuantity + OriginalItemQuantity - NewItemStackLimit;
OriginalItem->EquipedPosition = NewItemPosition;
// add maximum quantity to original item position
NewItem->Quantity = NewItemStackLimit;
NewItem->EquipedPosition = OriginalItemPosition;
Inventory[OriginalItemPosition] = NewItem;
Inventory[NewItemPosition] = OriginalItem;
}
// if no remanent, item should be replaced with empty space
else
{
// add both quantities
Inventory[OriginalItemPosition]->Quantity = NewItemQuantity + OriginalItemQuantity;
// delete item
Inventory.RemoveAt(NewItemPosition);
for (int32 Index = NewItemPosition; Index < Inventory.Num(); ++Index)
{
Inventory[Index]->EquipedPosition = Index;
}
}
}
else
{
// update inventory
Inventory.RemoveAt(NewItemPosition);
Inventory.Add(NewItem);
for (int32 Index = NewItemPosition; Index < Inventory.Num(); ++Index)
{
Inventory[Index]->EquipedPosition = Index;
}
}
}
}
OnEquipedUpdate();
OnInventoryUpdate();
bDragAndDropEnabled = true;
}
}
For item creation, I had to implement embedded data tables in Unreal Engine to facilitate the creation of these in the future and to be able to see them in an orderly manner, and then access them and create objects based on the stored characteristics.
Items data structure
Monsters
Like items, monsters are created in data tables to facilitate order in the future.
Monsters data structure
As they have similar characteristics to the characters, I used the same actor template to bring them to life. Unlike characters, monsters are already created in the game server once it is started and they are summoned again after being defeated.
Monster spawn test
The follow system positions the monster in a range of distance from the target, in case it is very far away it approaches using the MoveToActor function, and in case it is very close it moves away to the most convenient point (a polygon of 8 vertices is generated around the target of the monster, the closest point is evaluated and the MoveToLocation function is used to get there).
Monster movement test
Game server method to controll monsters
void AAlzadiAIController::SetFollowLockedTarget()
{
if (GetLocalRole() == ROLE_Authority)
{
if (IsLocalController())
{
// test if there is a locked target
if (BaseCharacterRef->LockedTarget)
{
if (BaseCharacterRef->GetDistanceTo(BaseCharacterRef->LockedTarget) > FollowingDistanceLimitUp)
{
MoveToActor(Cast<AActor>(BaseCharacterRef->LockedTarget));
}
// test if is too close to move farther
else if (BaseCharacterRef->GetDistanceTo(BaseCharacterRef->LockedTarget) <
FollowingDistanceLimitDown)
{
TArray<FVector> EscapePoints;
BaseCharacterRef->LockedTarget->GetPointsAroundCharacter(EscapePoints, 8, FollowingDistanceLimitUp);
EscapePoints.Sort([this](FVector A, FVector B)
{
float DistanceA = FVector::Distance(A, this->BaseCharacterRef->GetActorLocation());
float DistanceB = FVector::Distance(B, this->BaseCharacterRef->GetActorLocation());
return DistanceA < DistanceB;
});
for (FVector EscapePoint : EscapePoints)
{
if (bIsPointReachable(BaseCharacterRef->GetActorLocation(), EscapePoint))
{
MoveToLocation(EscapePoint);
break;
}
}
}
else
{
StopMovement();
}
}
}
}
}
Game server method to test distance
bool AAlzadiAIController::bIsPointReachable(FVector StartPoint, FVector EndPoint)
{
UNavigationSystemV1* AlzadiNavigationSystemV1 = FNavigationSystem::GetCurrent<UNavigationSystemV1>(
GetWorld());
ANavigationData* AlzadiNavData = AlzadiNavigationSystemV1->GetNavDataForProps(
GetNavAgentPropertiesRef());
if (AlzadiNavData)
{
TSubclassOf<UNavigationQueryFilter> FilterClass = UNavigationQueryFilter::StaticClass();
FSharedConstNavQueryFilter QueryFilter = UNavigationQueryFilter::GetQueryFilter(
*AlzadiNavData, FilterClass);
FPathFindingQuery MyAIQuery = FPathFindingQuery(
this, *AlzadiNavData, StartPoint, EndPoint, QueryFilter);
return AlzadiNavigationSystemV1->TestPathSync(MyAIQuery);
}
return false;
}
Widgets
Many of the above-mentioned themes have their widgets that display information to the user.
Widgets
A good thing of widgets is that they only exist in the clients and not in the game server, so the processing of these depends 100% on the user's machine and is fed by information sent by the game server. In general in my designs the information is updated every time a variable changes (for example, the health bar is reduced when the game server notices that the life of a character is decreasing, as is possible to appreciate in some of the examples above).
There is a lot of detail that I didn't explain, but I'm happy to be contacted if you need to understand how I programmed some parts.
Thank you very much for taking the time to read and appreciate my creations, let's stay in touch!