Back

Voidwalkers

Preface

Voidwalkers is a round-based survival game where players face waves of zombies (Voidwalkers) that grow stronger and faster each round. The goal is to survive as long as possible by using the environment, upgrading gear, or finding and completing the main story. For those who enjoy exploring, there are multiple Easter eggs and plenty of options for different high-round strategies. Check out the commentated playthrough below or play it for yourself here!

Gameplay Video

Details

System Design

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

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

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

Code Snippets


    # Define zombie behavior class
    zombie_behavior := class(npc_behavior):
    
        # Access my Game Manager creative device
        @editable
        GameManager : game_manager = game_manager{}
    
        # Override function that begins the behavior by starting pathing on initialization
        OnBegin(): void =
            StartPathing()
    
        # Start pathfinding behavior for the zombie
        StartPathing(): void =
            if:
                # Retrieve zombie'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 zombie'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 zombie 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.PlayZombieSound(Agent)  # Play zombie sound
                                    Sleep(0.7)  # Short delay for animation timing
                                    GameManager.DealDamage(ClosestPlayer)  # Deal damage
                    else:
                        # Stop zombie's navigation if no valid target
                        Navigatable.StopNavigation()
                    Sleep(0.5)  # Loop delay to prevent the game from crashing
    

        

The zombie_behavior class defines the behavior of the zombies. 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 zombie first retrieves its own agent, character, and navigation components. The zombie then continuously checks for the closest target using the game manager. If the closest target (a player) is found and is not the zombie itself, and if the game manager allows pathing, the zombie 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 zombie fails to find a valid target, it stops navigation. The process loops with a small delay, allowing the zombie to continuously look for and engage with nearby players.


    #Player map to track the players' activity status
    var PlayerMap : [agent]logic = map{}
    #Zombie map to to store all active zombies
    var ZombieMap : [agent]int = map{}
    #Zombie stats
    var ZombieHealth : float = 30.0
    var ZombieDamage : float = 25.0
    var BaseZombieSpeed : float = 0.6
    var MaxZombieSpeed : float = 0.8
    var ZombieSpeedCap : float = 1.1
    #Zombie spawners
    var SpawnerNumber : int = 0
    var TotalSpawners : int = 1
    #Round Settings
    var MaxZombiesPerRound : int = 10
    var ZombiesSpawnedThisRound : int = 0
    var RoundNumber : int = 1
    #Zombies alive at the same time
    var MaxActiveZombies : int = 50
    var ActiveZombies : int = 0;
    #Zombie round buffs
    var ZombieIncreasePerRound : int = 5
    var ZombieSpeedIncreasePerRound : 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. Zombies 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.

Zombie spawners generate a set number of enemies per round, but there's a limit on how many zombies 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 zombies from targeting the location of dead players and dealing damage when they reach that spot.


    # Ends the round by buffing zombies and setting up parameters for the next round
    EndRound() : void =
        # Play sound to signal the end of the round
        RoundEndAudioPlayer.Play()
        
        # Apply buffs to zombie stats for the next round
        BuffZombies()
        
        # Increment the round number and reset zombie counters
        set RoundNumber += 1
        set ActiveZombies = 0
        set ZombiesSpawnedThisRound = 0
        
        # Get the collection of teams in the game
        TeamCollection := GetPlayspace().GetTeamCollection()
        Teams := GetPlayspace().GetTeamCollection().GetTeams()
        
        # Set maximum number of zombies for the next round based on team size
        if (PlayerTeam := Teams[0], TeamMembers := TeamCollection.GetAgents[PlayerTeam]):
            set MaxZombiesPerRound += ZombieIncreasePerRound * 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 zombies at the end of a round to increase their speed slightly
    BuffLastZombies() : void=
        for (Zombie->Value : ZombieMap):
            if (ZombieCharacter := Zombie.GetFortCharacter[], ZombieNavigatable := ZombieCharacter.GetNavigatable[]):
                # Increase movement speed of each remaining zombie slightly
                ZombieNavigatable.SetMovementSpeedMultiplier(1.05)
    
    # Increase zombie base stats, including health and speed, at the start of each new round
    BuffZombies() : void=
        # Increment zombie health slightly for the new round
        set ZombieHealth += 5.0
        
        # Increase zombie speed if it hasn't reached the speed cap
        if (MaxZombieSpeed < ZombieSpeedCap):
            set BaseZombieSpeed += ZombieSpeedIncreasePerRound
            set MaxZombieSpeed += ZombieSpeedIncreasePerRound

        

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

Additionally, the maximum number of zombies 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 zombie and return that player
    GetClosestTarget(Zombie : 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 zombie and each player character, storing each in ZPDistancesMap
            for (TeamMember : TeamMembers):
                if (PlayerCharacter := TeamMember.GetFortCharacter[], PlayerCharacter.IsActive[]):
                    if (ThisZombie := Zombie.GetFortCharacter[]):
                        # Calculate the 3D distance between the zombie and player and store it in ZPDistancesMap
                        if (set ZPDistancesMap[Sqrt(Pow(PlayerCharacter.GetTransform().Translation.X - ThisZombie.GetTransform().Translation.X, 2.0)
                        + Pow(PlayerCharacter.GetTransform().Translation.Y - ThisZombie.GetTransform().Translation.Y, 2.0)
                        + Pow(PlayerCharacter.GetTransform().Translation.Z - ThisZombie.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 zombie itself if no players are found
        return Zombie

        

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

(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 zombie 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 zombie itself.

This function is called in the npc_behavior, which stores the closest player and directs the zombie to navigate toward that player until the navigation is interrupted or the player dies. In either case, the zombie 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 overview of the map and its size. A main street runs through the entire map, lined with the four houses and other points of interest along the way. Trees are used to block the view beyond the map boundaries. The players spawn in the center, initially blocked from the rest of the map until they find and bring three cube fragments to the altar in the middle.

Here you can see the overview in more detail.

The red and yellow houses sit side by side, with a shed and a small garden area behind them. Each house has its own vending machine stocked with weapons that match its theme, and there's an ammo box in front of the yellow house.

The pink and blue houses are set a bit farther apart and are separated from the red and yellow ones. Each has its own unique vending machines, and the blue house even includes extra defenses. There's also another ammo box in front of the basketball court located between the houses.

I go over the mechanics in each house in my commentated playthrough, but here are some interior screenshots to give you an idea of each house's unique theme.

Yellow house interior.

Pink house interior.

Blue house interior.

Vending machines are scattered around the map, giving the players essential consumables like airstrikes and dynamite, which are very important for reaching higher rounds.

Gas station vending machine.