Back

The Colosseum

Preface

The Colosseum is a round-based survival game where players face waves of creatures that grow stronger and faster each round. The goal is to survive as long as possible by using the environment, unlocking gear, or the defeating the champions of the arena. For those who enjoy exploring, there are multiple Easter eggs and plenty of options for different high-round strategies. Check out the trailer below or play it for yourself here!

Trailer

Details

System Design

The two most important loops in this game are the core gameplay loop and the creature behavior loop.

Players take down waves of creatures to earn gold, which they can use to upgrade and buy new weapons and gear. These upgrades help them survive as the creatures get stronger each round, creating a continuous cycle of fighting, earning gold, and gearing up.

Each creature follows a simple pattern: it finds the nearest player, moves toward them, and attacks when close enough. This keeps the creatures constantly on the hunt, pushing players to stay alert and adapt as the horde grows.

Code Snippets


    creature_behavior := class(npc_behavior):
    
        # Access my Game Manager creative device to access functions
        @editable
        GameManager : game_manager = game_manager{}
    
        # This function begins the behavior by starting pathing on initialization of the game
        OnBegin(): void =
            StartPathing()
    
        # Start pathfinding behavior for the creature
        StartPathing(): void =
            if:
                # Retrieve creature's character agent, its Navigatable and instantiate
                # a variable for the closest player
                Agent := GetAgent[],
                Character := Agent.GetFortCharacter[],
                Navigatable := Character.GetNavigatable[],
                var ClosestPlayer : agent = Agent
            then:
                # Loop to continuously update creature's target and actions
                loop:
                    # Use the game manager to determine the closest target player
                    set ClosestPlayer = GameManager.GetClosestTarget(Agent)
                    
                    # If a valid target is found and pathing is allowed
                    if (ClosestPlayer <> Agent):
                        if (GameManager.ShouldPath = true):
                            # Set navigation target to closest player
                            NavTarget := MakeNavigationTarget(ClosestPlayer)
                            NavResultGoTo := Navigatable.NavigateTo(NavTarget)
                            
                            # If the creature reaches the target, trigger attack animation and sound
                            if (Anim := Character.GetPlayAnimationController[]):
                                if (NavResultGoTo = navigation_result.Reached):
                                    Anim.Play(Mixamo.AttackAnim)  # Play attack animation
                                    GameManager.PlayCreatureSound(Agent)  # Play creature sound
                                    Sleep(0.7)  # Short delay for animation timing
                                    GameManager.DealDamage(ClosestPlayer)  # Deal damage
                    else:
                        # Stop creature's navigation if no valid target
                        Navigatable.StopNavigation()
                    Sleep(0.5)  # Loop delay to prevent the game from crashing
    

        

The creature_behavior class defines the behavior of the creatures. It inherits from the npc_behavior class and interacts with the game manager creative device to manage its actions.

When the game starts, the OnBegin function is called, which triggers the StartPathing function. In this function, the creature first retrieves its own agent, character, and navigation components. The creature then continuously checks for the closest target using the game manager. If the closest target (a player) is found and is not the creature itself, and if the game manager allows pathing, the creature starts navigating toward this target. If it successfully reaches the target, it plays an attack animation and triggers a sound effect, while also dealing damage to the player. If the creature fails to find a valid target, it stops navigation. The process loops with a small delay, allowing the creature to continuously look for and engage with nearby players.


    #Player map to track the players' activity status
    var PlayerMap : [agent]logic = map{}
    #Creature map to to store all active creatures
    var CreatureMap : [agent]int = map{}
    #Creature stats
    var CreatureHealth : float = 30.0
    var CreatureDamage : float = 25.0
    var BaseCreatureSpeed : float = 0.6
    var MaxCreatureSpeed : float = 0.8
    var CreatureSpeedCap : float = 1.1
    #Creature spawners
    var SpawnerNumber : int = 0
    var TotalSpawners : int = 1
    #Round Settings
    var MaxCreaturesPerRound : int = 10
    var CreaturesSpawnedThisRound : int = 0
    var RoundNumber : int = 1
    #Creatures alive at the same time
    var MaxActiveCreatures : int = 50
    var ActiveCreatures : int = 0;
    #Creature round buffs
    var CreatureIncreasePerRound : int = 5
    var CreatureSpeedIncreasePerRound : float = 0.05
    #User Interface
    var UIPerAgent : [agent]?tuple(canvas,text_block, text_block, text_block, text_block) = map{}
    #Pathing logic to avoid post mortem pathing and damage
    var ShouldPath : logic = true
    #Score
    var TotalElims : int = 0

        

To give some context for the next snippets, these are my variables. Creatures have defined base stats, and with each round, they become faster and more numerous, with stats like health and speed scaling to increase the challenge. However, there is a speed cap to keep things fair.

Creature spawners generate a set number of enemies per round, but there's a limit on how many creatures can be active (alive) at the same time to ensure stable performance.

Players' progress is tracked by the total eliminations, which is part of the UI that is stored in the UIPerAgent variable.

The ShouldPath variable is very important because it stops creatures from targeting the location of dead players and dealing damage when they reach that spot.


    # Ends the round by buffing creatures and setting up parameters for the next round
    EndRound() : void =
        # Play sound to signal the end of the round
        RoundEndAudioPlayer.Play()
        
        # Apply buffs to creature stats for the next round
        BuffCreatures()
        
        # Increment the round number and reset creature counters
        set RoundNumber += 1
        set ActiveCreatures = 0
        set CreaturesSpawnedThisRound = 0
        
        # Get the collection of teams in the game
        TeamCollection := GetPlayspace().GetTeamCollection()
        Teams := GetPlayspace().GetTeamCollection().GetTeams()
        
        # Set maximum number of creatures for the next round based on team size
        if (PlayerTeam := Teams[0], TeamMembers := TeamCollection.GetAgents[PlayerTeam]):
            set MaxCreaturesPerRound += CreatureIncreasePerRound * TeamMembers.Length
            
            # Update the UI for each team member with new round stats
            for (TeamMember : TeamMembers):
                UpdateUI(TeamMember)
        
        # Start the timer for the next round
        TimerToNextRound.Start()
    
    # Buffs the last few creatures at the end of a round to increase their speed slightly
    BuffLastCreatures() : void=
        for (Creature->Value : CreatureMap):
            if (CreatureCharacter := Creature.GetFortCharacter[], CreatureNavigatable := CreatureCharacter.GetNavigatable[]):
                # Increase movement speed of each remaining creature slightly
                CreatureNavigatable.SetMovementSpeedMultiplier(1.05)
    
    # Increase creature base stats, including health and speed, at the start of each new round
    BuffCreatures() : void=
        # Increment creature health slightly for the new round
        set CreatureHealth += 5.0
        
        # Increase creature speed if it hasn't reached the speed cap
        if (MaxCreatureSpeed < CreatureSpeedCap):
            set BaseCreatureSpeed += CreatureSpeedIncreasePerRound
            set MaxCreatureSpeed += CreatureSpeedIncreasePerRound

        

These two functions handle the end of a round. When a round ends, an audio cue plays, creature stats are buffed, the round number is increased, and creature-related counters (ActiveCreatures, CreaturesSpawnedThisRound) are reset.

Additionally, the maximum number of creatures for the next round is adjusted based on the number of players on the team and the UI is updated to reflect the new round for each team member. Afterwards, a timer starts to count down to the next round.


    # Get the closest player to a specified creature and return that player
    GetClosestTarget(Creature : agent) : agent = 
        # Initialize an array to store distances and a map to associate distances with players
        var MagnitudeArray : []float = array{}
        var ZPDistancesMap : [float]agent = map{}
        
        # Retrieve the collection of teams within the playspace
        TeamCollection := GetPlayspace().GetTeamCollection()
        Teams := GetPlayspace().GetTeamCollection().GetTeams()
        
        # Check if the first team in the list is the player team and get its members
        # The reason for this way of getting the players and not using GetPlayers() is so that I can test with AI
        if (PlayerTeam := Teams[0], TeamMembers := TeamCollection.GetAgents[PlayerTeam]):
            
            # Calculate distances (magnitudes) between the creature and each player character, storing each in ZPDistancesMap
            for (TeamMember : TeamMembers):
                if (PlayerCharacter := TeamMember.GetFortCharacter[], PlayerCharacter.IsActive[]):
                    if (ThisCreature := Creature.GetFortCharacter[]):
                        # Calculate the 3D distance between the creature and player and store it in ZPDistancesMap
                        if (set ZPDistancesMap[Sqrt(Pow(PlayerCharacter.GetTransform().Translation.X - ThisCreature.GetTransform().Translation.X, 2.0)
                        + Pow(PlayerCharacter.GetTransform().Translation.Y - ThisCreature.GetTransform().Translation.Y, 2.0)
                        + Pow(PlayerCharacter.GetTransform().Translation.Z - ThisCreature.GetTransform().Translation.Z, 2.0))] = TeamMember){}
            
            # Transfer distance (magnitude) keys from ZPDistancesMap into MagnitudeArray
            for (Key->Value : ZPDistancesMap):
                NewMagnitude : []float = array{Key}
                set MagnitudeArray = MagnitudeArray + NewMagnitude
            
            # Sort MagnitudeArray in ascending order so the lowest distance is at index 0
            for (IDX := 0..MagnitudeArray.Length - 1):  
                if (MagnitudeArray[IDX] > MagnitudeArray[IDX + 1], Temp := MagnitudeArray[IDX], Current := MagnitudeArray[IDX + 1]):
                    if (set MagnitudeArray[IDX] = Current, set MagnitudeArray[IDX + 1] = Temp){}
            
            # Iterate through sorted distances and return the closest active player
            for (Index := 0..MagnitudeArray.Length - 1):
                if (ClosestPlayer := ZPDistancesMap[MagnitudeArray[Index]]):
                    if (ClosestPlayerCharacter := ClosestPlayer.GetFortCharacter[], ClosestPlayerCharacter.IsActive[]):
                        return ClosestPlayer  # Return the closest active player
            
        # Return the creature itself if no players are found
        return Creature

        

One special function that I want to show here is the GetClosestTarget function. It calculates and returns the closest active player to a given creature.

(But this code can be used for any agent.)

It first retrieves the team members from the game's Playspace, then calculates the distance between the creature and each active player using their 3D coordinates. These distances are stored in a map, which is then sorted to find the player with the smallest distance. Finally, the function checks if the closest player is still active (alive) and returns that player. If no active player is found, it returns the creature itself.

This function is called in the npc_behavior, which stores the closest player and directs the creature to navigate toward that player until the navigation is interrupted or the player dies. In either case, the creature then searches for a new target.

Map Design

Below, you'll find details about the map and its specific sections. Please note that some details may vary in future versions of the game.

This is the starting area of the map. It's where the player, this round's contender, spawns in. The exits are blocked by iron gates and will only open once the player picks up a weapon, showing they've accepted the challenge. The final boss and champion of the colosseum which you saw in the trailer spawns here as well.

As soon as the gates open, creatures start spawning in. They emerge from dedicated holes in the walls, dropping down and starting to chase the player.

One of the things the player can find on the map is four god ritual sites. These can be initiated by touching a dark skull on the front of a ritual pillar. Once it's touched, the player has to hold the area and defend it from ancient monsters that spawn in periodically and attack until the god is satisfied.

When the god is pleased, a colored ray of magic bursts from their eyes, bringing the water in one of the four pots to a boil. This makes a sphere with the same color as the ray appear from the pot. The player can then shoot the sphere to earn a powerful weapon.

Once all four rituals are complete, one of the colosseum gates opens, and a Descendent of the Underworld emerges. It blocks the way to a treasure that can only be accessed after defeating it.

There are also two god temples in the colosseum. The first is the Halls of Hades, which houses the powerful Chains of Hades, one of the most powerful weapons in the game. The temple can be unlocked by sacrificing 1,000 gold at the shrine in front of it.

The other temple is the Temple of Zeus, which also houses a powerful weapon and can be unlocked the same way as the Halls of Hades, except the gold needs to be brought to the statues in front of the temple.

The next area is the graveyard. In ancient times, the gods sent down mighty creatures to challenge the strongest contenders. Now, only their bones remain.

The graveyard also acts as an altar where players can unlock the second gate in the arena. This gate reveals a treasure but also summons a horde of wolves that guard it fiercely.

Lastly, the labyrinth is where consumables can be bought. It needs to be unlocked by sacrificing 1,000 gold at the statues by the door. Once open, all players can purchase powerful buffs and items, for a price.

That's it! This was such a fun project to work on. It was partly inspired by the Call of Duty: Black Ops 4 Zombies map, IX. It's one of my favorite Zombies maps of all time, and I wanted to create something that captured a similar vibe while offering a unique experience.

It took me around two weeks to make and throughout the process, I learned a lot about programming AI in Verse, designing exciting rewards and challenges for players, and integrating fiction with mechanics to create a cohesive experience. I'm happy I got to share this with a lot of Fortnite players, and I hope you enjoyed it as much as I enjoyed creating it!