Skip to content

An RTS game crafted in Unreal Engine, inspired by Age of Empires 2, and developed using C++

License

Notifications You must be signed in to change notification settings

MrHaloka/age_of_heros

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

76 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Age of Heros

Age of Heroes is an RTS game made in Unreal Engine using C++. The game is high-performance and easy to extend. You can find the explanation of each part in the Code Exploration. I will document more of the codebase as I add new features. you can find me here:

If you find this repository interesting, please consider giving it a ⭐️. Your support means a lot and helps others discover it too. Thank you!

Introduction

I started this project to deepen my understanding of C++ and game programming while also expanding my portfolio, inspired by my passion for Age of Empires II. My ultimate goal is to create a multiplayer 1v1 game featuring AOE2 community heroes such as Viper, Hera, Daut, T90, and more. While there's still a long way to go to complete this project, I'm excited about the journey ahead.

Throughout the project, I aimed to maintain high performance and readability. If you encounter any problems or notice any missing features, feel free to contact me or create a new issue.


Code Exploration

In each section, I first explain the problem and then the solution I chose for it and why. In the subsections, I dive more deeply into how things work under the hood.

Note

This list will grow as I add new features to the project and find more time to document the existing ones.

Object Manager

#include "Managers/ObjectsManager.h"

One of the most important parts of any RTS game is being able to handle units based on their location. Whenever a player tries to select a unit, units collide with each other, or even finding a path.

Tip

There are a few other ways you can solve this problem, but using spatial hash is one of the most optimized and easiest for games like this.

Objects Manager is a class that, each time a match starts, the game mode creates an object from to store our actor data.
Its primary function is to allow constant-time retrieval of actors based on their locations or grid positions, eliminating the need to loop through all actors.
it creates a FSpatialHash2d based on its grid and cell size. Every time a unit spawns, it assigns that unit an ID and adds it to the HashGrid. It updates if a unit moves.

Methods like:

  • GetUnitInLocation (to find a unit in a location)
  • IsBlock (to check if a grid is blocked by static objects like buildings)
  • AdjacentNeighbours (to get all the units in all the adjacent neighbors)

are some of the functions you can find inside Managers/ObjectsManager.

  • Spatial Hash

#include "Containers/SpatialHash2d.h"

A spatial hash is a data structure used to efficiently manage objects based on their positions within a virtual space. It divides the space into a grid of cells, allowing for quick retrieval of objects located in a particular region. Each object is assigned to one or more cells based on its position. This enables fast queries such as finding objects within a certain area or checking for collisions between nearby objects. Spatial hashes are particularly useful in real-time strategy (RTS) games where units often interact based on their proximity in the game world.

SpatialHash2d is the class that we use to store our units inside a spatial hash. It initially creates its own TMultiMap to store units base on thier location and GridIndexer2d to create an index for those locations. this class is fully commented on what each function does.
you should be able to find it in Containers/SpatialHash2d.

  • Indexer
#include "DataStructures/GridIndexer2d.h"

GridIndexer2d has the role of assigning an ID to each location based on the grid they're on. It starts from the left bottom corner as 0 and the right top corner as the maximum ID value. From there, it adds 1 each time it moves to the next right grid neighbor.When each row ends, it moves to the upper row and continues adding IDs until it reaches the last grid on the top right.(see Figure 1)

whenever SpatialHash2d needs to access a point(units) on grid, it calls the indexer's ToGrid function. This function takes an FVector2D location as its input and returns the ID that represents its grid in the hash map.

Figure 1

Figure 1
Indexer ids for a 3x3 grid

  • Iterators
#include "Iterators/SpatialHashIterator.h"

There are occasions when need to iterat over collection of grids to access the data we need. a common example is when we're searching for units within a certain radius of a location. To make iterating over grids and debuggin easier, there's the SpatialHashIterator class that can handle 2 different types of iterations.

The first type:
when you have the query center and you want to iterate over all grids that are within a radius.
FSpatialHashIterator(const FGridIndexer2d& Indexer, const FVector2d& QueryPoint, float Radius)
The second type:
when you have min and max locations that you want to iterate through. (A simple example of this behavior is when a player drag selects in an RTS game.)
FSpatialHashIterator(const FGridIndexer2d& Indexer, const FVector2d& MinPoint, const FVector2d& MaxPoint)

Tip

Both of these methods have an Iterate() and IsFinish() method. Every time you iterate, it will provide you with an ID for the current grid.

Pathfinding

One of the central problems in game AI is finding short and realistic-looking paths. Pathfinding is typically divided into two steps: discretize and search. First, the discretize step simplifies a continuous environment into a graph. Second, the search step propagates information along this graph to find a path from a given start vertex to a given goal vertex. Video game developers (and roboticists) have developed several methods for discretizing continuous environments into graphs, such as 2D regular grids composed of squares (square grids), hexagons or triangles, 3D regular grids composed of cubes, visibility graphs, circle-based waypoint graphs, space-filling volumes, navigation meshes, framed quad trees, probabilistic road maps, and rapidly exploring random trees [Björnsson 03, Choset 04, Tozour 04].

Now that we have our objects manager ready the next important part is pathfinding. in our game we have a grid-based world ready. new static object (such as building and resources) can spawn or get destroy during game, meaning our enviorment is fully dynamic. the initial solution to our problem is using A* pathfinding and use our grids as graph nodes. However, the primary limitation of A* is that each path's parent is always one of its neighbors, meaning our units can only move left-right or up-down resulting the suboptimal and robotic path.(see Figure 2) that's why instead of using A* we're using theta* that builds upon the basic principles of A* and make anyangle-pathing possible. (see Figure 2)

Figure 2

Figure 2
Showcasing theta-star pathfinding compare to A-star.

  • Theta star

#include "Pathing/ThetaStar.h"

the most important reason of using theta* is ani-angle pathing. thet* adds an extra condition to what A* has and that's checking for line of sight. Two nodes have line of sight if a line can be drawn between them without encountering any obstacle.

Tip

Theta* properties :
G: cost of moving from the start node to this node
𝐻: cost of moving from this node to goal (distance from current node to end node)
F: is the total cost of this node. (G + H)

Like A* we have 2 sets of list (Opens and Closes) and a priority_queue of open ids to efficiently search throgh nodes. Open ids are sorted based on their F values.

When an object created from Pathing/ThetaStar class. it first set the objects manager that it works with then create a line of sight component (explained in Ray casting) that it use for visibility checks. Next, it calculate the F value for the starting point (where the unit currently is) and push it to the open ids.

then in CalculateThePath() method it get the lowest score from priority_queue and obtains its neighbours, calculate thire G, and adds them to open ids.

in CalculateAndUpdateG() we calculate the G with 2 diffrent path:

  • the first one (A*) computes the distance from its parent plus its parent's G value
  • In the second path, it checks if the current node has line of sight to its grandparent. If it does, then it calculates the distance from the current node to its grandparent plus the grandparent's G value.

To make working with nodes easier there's a FThetaVertex data structre that has all the necessaris data in it:

FVector2d GridLocation;
double G;
double H;
uint32 Parent;
uint32 GridId;
  • Ray casting

#include "Components/LineOfSightComponent.h"

To make theta* work, we need a way to check visibility line between 2 points.Since our blocking objects (resources like trees or buildings like houses) are grid-based, meaning they block a full grid, all we need to do is determine which grids lie between our two points. If there's no blocking point in any of those grids, we can draw a visibility line without any issues.

To find all those grids, we use Bresenham's line algorithm. This makes our ray casting much faster as we only check the grids that are part of the line.(see Figure 3)

For performing any raycast, you can easily use the FLineOfSightComponent, which takes an FObjectsManager to check for blocking grids

Tip

You can use HasLineOfSight() method to check if 2 points has visible line to each other or use GetBlockingObjects() to get all the blocking objects in between.

Figure 3

Figure 3
Grids that are checked to see if this visibility line is blocked by any static object.

  • Post process

#include "Pathing/ThetaStar.h"

Now that we have the path, we need to consider our unit's collision radius. In Figure 4-A, you can see how the post-process attempts to adjust the path within the grid to prevent collisions between moving and static objects.

After Theta* finds the path, it calls the PostProcess() method. This method draws two extra lines from the right and left collision sides of the unit and checks for any blocking objects. Whenever it finds one, it saves those objects in a list. (Figure 4-B)

Later, it calls ChangePathForCollisionSize() with the locations of the blocking objects. This function tries to find the closest corner of the blocking objects to the path and pushes the path away from the center of the blocking object to prevent collisions. (Figure A-C)

Figure 4

Figure 4
A) Theta-star finding the path, and PostProcess making it collision-free for the unit. B) Two collision lines on both the left and right sides (red) of the Theta-star path to detect any collisions within the current path. C) PostProcess pushing the Theta-star path away from blocking points.

  • Steering

#include "Components/Movement/SteeringComponent.h"

Steering behaviors are algorithms used to calculate the desired direction and speed for a unit to move towards a target or avoid obstacles. These behaviors can be combined to create complex and realistic movement patterns.

Pathfinding can now calculate a path that is optimal for our unit and avoids any static objects. But what about moving units? This is where we start using steering forces.

  • Seek: This behavior calculates the velocity needed for a unit to reach a goal position..
  • This behavior checks for nearby units. If another unit is detected, it calculates the time to collision (TTC). If the TTC is within the collision range, it adjusts the velocity to ensure the unit avoids the other unit.

Note

This list will grow as more steering types are added.

Let's discuss how steering velocities work in Age of Heroes. After AMoveableUnit gets the path from theta*, it calls seek on each tick to move toward the first goal. When the unit reaches this target, it updates to the next goal and repeats the process.

in Seek() method it first calculate the goal direction from current unit position and then multiplies it by the unit speed.

After Seek() calculated the goal velocit, moveable, calls the Avoidance() method. this method, at first get all the other moveables in a radius and then calculate the TTC.

Tip

TTC equation is based on (unit postion + unit velocity * TTC ) - (obstacle postion + obstacle velocity * TTC ) = unit collision radius + obstacle collision radius
Check TimeToCollision() method inside steering component for more information.

If a collision is imminent, it will call the avoidance with obstacle data and TTC(Time to Collision). This method then calculates the necessary velocity needed to keep the objects out of each other's way.

The smaller the TTC becomes, the higher the avoidance force weight gets. This weight creates more realistic behavior for units, as the closer they are to each other, the more they deviate from their path to avoid collisions.

There's still another problem that need to be solved. If a unit changes its direction at the last second, it can block the other unit since TTC is 0 and a collision has already happened. to fix this probelm, Avoidance() calls TurnoffMovingCollisionTemporary() to change the unit collision radius to 1. this allows both units to move around each other without a problem of getting stuck.

Perception system

#include "Managers/PerceptionManager.h"

Perception manager is responsible for adding sight to units, allowing them intract with the world around them. it gives units ability to sense their surroundings and other units. Perception Manager is a class that sits on top of the Objects Manager. In an RTS game, where there can be many units, the performance of the perception system is crucial. This system is designed to detect units only when they change states. If all units in the game remain idle and nothing changes, the perception system imposes no overhead on the CPU.

Perception manager has its own hash grid that used to attach grids to units. whenever a units spawn in the world and has SightComponent, the perception manager marks all the grids that are within its perception radius with that unit id. if any unit moves or spawns inside those marked grid, perception manager will brodcast the event to let the SightComponent know that a unit has been sensed.

  • Sight component

  #include "Components/AI/SightComponent.h"

The Sight Component allows a unit to communicate with the Perception Manager. Upon adding this component to any unit, it automatically registers its owner with the perception system. If any component or its owner needs to use the perception system later on, they can utilize the built-in event OnSenseUnitEvent and add their listener to the perception system.

Combat system

#include "Components/Combat/BaseCombatComponent.h"

In an RTS game, the combat system consists of different phases. First, it needs to find a target, which can happen automatically when units are on patrol and sense an enemy or when a player clicks a new target to attack. Then, if the unit is mobile, it should chase its target and stay in range to be able to attack it (e.g., scouts, archers). If the unit is stationary, it should try to attack as long as the enemy is in range (e.g., towers).

The BaseCombatComponent includes configurable variables such as MaxRange, MaxPursuitRange, ReloadTime, Cooldown, Damage, and AttackLength. Its functionalities are designed to be easily configurable for different types of attacks (such as ranged or melee). To add the ability to fight, you simply add this component to any unit type you want. It attempts to register itself with the SightComponent, if possible, and listens for its owner's state changes.

Tip

The base component is fully commented. I have explained each function and how you can create new attack types as thoroughly as possible.

  • Melee attack

 #include "Components/Combat/MeleeAttackComponent.h"

Melee range is the simplest type that can be create using the BaseCombatComponent. As the base component already has all the necessary functionalities, you just need to set the appropriate variables.

  • Range Attack

 #include "Components/Combat/RangeAttackComponent.h"

For the range attack component, a few changes need to be made. The first change is in how PerformAttack() works. Instead of dealing damage directly, it needs to spawn a projectile and shoot at the target. I will explain how projectiles work in the next part. As archers need to stay still to shoot, they have an extra method AttackCompleted() that gets called after their attacking animation finishes.

The Range Attack Component includes a property named ProjectileType that allows you to specify the type of projectile this component uses to attack.

Reading this class is a good way to undrestand how making custome attack type works.

  • Projectiles
#include "Actors/Projectils/BaseProjectile.h"

BaseProjectile has all the necessary methods for a projectile.InitializeProjectile() sets the location from which the projectile will start moving and sets the target. Then, it calculates the starting Z velocity to ensure the projectile hits the target.

The damage each projectile inflicts should be set before fully spawning the projectile, as arrows with different upgrades can do varying damage. However, other properties like speed and collision radius, which are consistent across all instances of the same projectile type, are loaded from the game mode (same variables across all objects).

To add a new type of projectile, you need to create a new blueprint from BaseProjectile or any of its children. You can add the static mesh of the projectile in the blueprint and need to add a new row to the Projectile.CSV file inside the data directory.


Showcases

Pathfinding

multi agents avoidance

Resource harvesting and dropoff

multi agents avoidance

Range attack & projectiles (tower and archer)

multi agents avoidance

Melee attack & chasing

multi agents avoidance

Collision avoidance

multi agents avoidance

Releases

No releases published

Packages

No packages published