Frenzies: The Objective Actor
Frenzies: The Objective Actor
- The Background:
So for the first post on Frenzies, I'm choosing one of the first major systems I created as part of Frenzies, which has held up the gameplay since. So what is it? Well, the gameplay in Frenzies consists of multiple different rounds. Some of these rounds are your regular shooter fare (like free for all or team death match), but there are also some rounds unique to Frenzies, like Glitter Pig, a round where you chase a pig around the map, and Push (called Button Bash in game), a round where you need to get to the opponent's side of the map and press a button. Some of these different rounds will have different victory conditions, different round lengths, different team requirements and even variations in the levels needed to support them. They may even have mid-round gameplay behaviour, for example in Red Light Green Light, controlling the lights and when players are able to shoot enemies. The objective actor was created to provide a way to handle this, so the rest of the gameplay can just interface with the objective actor without having to care about the details of the particular round. While the class and subclasses has inevitably ended up slightly bloated as a result of myself and other coders adding more functionality over its 2+ years of use, the fact that the system is one we were all able to easily work with and expand is something I'm proud of.
![]() |
| One of the round types in Frenzies is Glitter Pig, where players have to chase and hold a pig longer than their opponents |
- The implementation:
First thing to discuss, the choice for this to be an actor. Looking back on this now, I think there's an argument that this system could be a subsystem, but there are actually some strong advantages for it being an actor. The first is replication. Spawning a replicated actor on the server allows for easy replication of server controlled variables. Also the ability to tick can be very important for certain round types, as well as general behaviour like controlling countdown updates. Additionally, it allowed the objective actors to be easily created and destroyed, so overall I'm happy with the decision.
Let's go through the basic flow of a round, from the point of view of the objective actor:
- The objective actor is spawned between rounds. In the configuration for the next round, the subclass of objective actor is stored. This means that the actor is only created when it is used, and of the correct subclass for the round type.
- The objective actor prepares the level. When the map is loaded, the objective actor will check its tags and use them to determine which round specific actors should be present in the level. An example of this is the domination points (where teams must compete to capture points by holding on to them). If the actors present in the level do not match the right tags for the round, they are removed. The remaining actors are relevant for the objective, so the objective actor caches references to them for later use. At this point, subclasses can do round specific behaviour, like choosing the giant player for the Boss Fight mode (1 giant player against the rest).
- The round begins. At this point there's another function call within the objective actor, to allow subclasses to handle anything that's needed on active gameplay beginning, with a mirrored call to do the opposite on round end also existing. The active gameplay controls are also separate functions within the class which can be called outside of this point, which is important, because this allows gameplay to be paused for debugging and QA purposes. This proved invaluable over the course of development, as it allows for testing and moving around the map without the active gameplay behaviour running.
- The round is handled by the specific subclass. Now the round is in progress, the subclass can handle the specific gameplay. This is left up to whatever the subclass wants to do; it could be that it handles stuff in tick or in callbacks, or that it does very little and leaves that to the objective specific actors in the level that were cached earlier.
- Win conditions are evaluated. There are two ways a round can end; either by the time for the round running out, or by something happening inside the round that causes it to end (for example accumulating a certain amount of score, like in the tug of war mode, where you have to dance on your opponent's side of the map to pull score for your team). As such, there's a function for handling the timeout (which can also do any cleanup in the map before the outro sequence), but also a separate EvaluateObjective function returning a bool, that subclasses can use, by adding a call to the base class function CheckObjectiveAndBroadcastIfComplete. Any time an event happens in a round that could potentially end the game, subclasses can call this, which will in turn call their round specific EvaluateObjective function. Should it return true, the base class will handle the fact that the round has ended in the flow.
- Teams are ranked. When the round ends, we need to know which order the teams came in. However, this isn't trivial. For some rounds, the number of kills decides who wins. For other, it is score from being in a specific place, or the number of points controlled, or even in an unreleased race mode, the number of checkpoints passed. I'm going to go into a bit of detail on my solution below, as I think it's a clean and efficient way of solving the issue.
After this, the players are sent back to the lobby with their team ranking and scores ready to show on the leaderboards, the objective actor is destroyed, and if there is another round to play a new one will be created, and the cycle starts again.
- Code spotlight: Ranking the teams:
So, how do you find an easy way of getting a ranking of teams for arbitrary conditions, without writing large amounts of bespoke behaviour for each class? Well first, by using a templated function. Logically, round types must have the information used to rank the teams in the first place, although that information can be in many different forms. For example, it could be the number of seconds each team has been on an objective, or the number of points they've got. These will be different data types; time will be a float or similar, whereas the number of points will be an integer. So it's important that our ranking function can handle this, and templating is required.
Following on from our logic earlier, we will have this data per team, so it should be represented in an array. Conveniently, we can use the index of our team as the index in the array, so that defines the inputs and outputs we have. Our input, an array of some unknown (templated) data type, and our output, a simple array of ints, where the index is the team and the value is its rank. But how do we handle the different data types? And what of more complex situations? For example, what if you want to have the team with the most kills win, but ONLY if they have lives left? A simple sort function using the templated type won't be able to handle more complex data than basic types - and besides, what if for some rounds a bigger number wins, but for others, a smaller does?
The solution? Pass in a predicate of the templated type to our ranking function. The predicate should have two inputs, both of the data type for a team, and should simply return a bool which is true if the first beats the second, and false if it doesn't. For a basic data type, like our amount of time, the predicate is incredibly simple: {return a > b;} For our example above with highest kills with lives remaining, the data was stored as a tuple (int32 kills, bool hasRespawns), and the predicate simply returns a if the int is bigger, unless b has respawns and a does not.
This approach worked really well, even allowing arrays of more complex structs to be passed in as the complexity of the game mode increased. It also allows us flexibility in the ranking function itself. For example, for the UI it was decided that the ranks had to start at 1, rather than zero. If each subclass implemented its own ranking function, it would have to be changed per game mode, risking bugs should one be missed, but with this approach it is a simple one-line change in the base class.
![]() |
| You can see a domination point to the left here, one of the actors in the map controlled by the objective actor, and removed from the map should that round type not be active |
Overall, I'm very pleased by how well the objective actor has worked in Frenzies, providing both flexibility and structure to the different rounds that now exist in the game. For something I added so soon into my first job as a game programmer, I'm very happy with how resilient and useful it has been.
P.S.: I'm sorry this post doesn't contain code samples; I'm waiting to hear if legally I can add them to my portfolio, and will update it if I can.


Comments
Post a Comment