Survivor's Arena
Synopsis:
This is a mod for Starfield where the player comes across an enigmatic NPC who runs a mysterious arena. In the arena, the player is tasked with going up against numerous waves of enemies, and upgrading their weapon and character, before fighting a final boss and completing the quest.
My Role: Solo Developer
|
Development Time: 3 months
Design Hightlights
- 15 custom scripts written
- 6 unique NPCs created
- 9 unique weapons created
- 14 custom spells created
- 8 custom perks created
Planning
The main thing I wanted to focus on getting right over everything else was the gameplay loop of the arena itself. This includes how to player first accesses the arena, how the enemy wave loop plays out, and what happens when/if the player leaves the arena. To that end, I created the below flow chart to map it all out.

Not everything here ended up working out as planned. Some things had to either be cut or modified, and some things were added.
Problems & Solutions
- Problem: Originally, I had planned on allowing the player to leave the arena at any time between enemy waves. This was simply to give the player more freedom as well as allow them to leave and get stronger before coming back if they were having trouble completing all of the waves. However, this resulted in an unforeseen issue with quest stages due to how the gameplay loop of the arena required repeatable stages. This also required more work to get around disabling/enabling saving as well managing any upgrades and points the player had received up to that point. - Solution: This easiest way to resolve this issue was to simply not allow the player to leave the arena once they entered, except until after they defeat the boss at the end. This also tied into the arena creator's character better. - Problem: At first, I had planned on removing any points the player had received from enemy kills during a wave if the player died. This ended up slowing down player progress a bit too much and made it more of a slog to get through all of the waves. - Solution: I simply made it so the player kept those points instead, which made it so the player could still feel like they were progressing a bit even after death. - Problem: Playing in the same arena every time gets repetitive/monotonous. - Solution: I created two more arenas for a total of 3, which then get randomly chosen for each wave.
Balance
Upgrades

Using a spreadsheet, I determined upgrade costs that align with the points players are likely earn per round. This system ensures players can max out the upgrades by the final round, accounting for missed kills, by making sure the total points possible are higher than the cost of maxed out upgrades.
It's worth noting, due to time constraints, this mod currently only has 5 rounds, with the 5th being a boss. There are also only 4 upgrades per category right now. A 5th upgrade would be added to each category once the number of rounds implemented reached 10. As it stands now, the player is able to get enough upgrades to defeat the boss in round 5 with a challenge level roughly equal to medium difficulty (based on player feedback).
Enemy Damage

Since I had to manually set the damage of all of the enemies in the mod, I balanced that damage using a scaling formula to suit all player levels. A spreadsheet helped map damage percentages of player health, from levels 1 to 100, ensuring a consistent and fair experience while factoring in enemy fire rate (not shown).
Scripting
WaveSpawner Script
StartWave
This is the main function of the wave spawner script. It initializes parameters for quest stage fragments and sets up related functions and scripts. It allows control over enemy wave aspects, including enemy type, spawn frequency, spawn cap amount, timer duration, and the quest stage to set upon timer completion.
StartCountdown
This is called inside the StartWave function. I modified the counter for the time-since-last-incident report board in Cydonia for the arena timer. I also control the spawn interval for health pickups here as well as some global variables to help with enemy spawning.
ArenaRandomizer
Within the StartWave function, two functions are called which set up a random arena for each wave. One function selects one of three arenas at random, and the other moves all essential markers, such as spawners for enemies and health pickups, the timer board, and the player, to the chosen arena.
SpawnNextEnemy
Once an arena is chosen, markers are moved, and the player is teleported, this function begins spawning the specified enemy type at set intervals. Enemies spawn at random markers from a stored array and are added to a reference collection for later cleanup. A recursive loop is used to maintain a continuous stream of enemies.
SpawnHealthPU
Health pickups spawn differently than enemies as they can't spawn at markers already occupied by other pickups. This function checks each marker for availability and will cycle through them until an empty one is found.
Problems & Solutions
- Problem: Longer enemy waves could result in too many enemies spawned in at one time, which started slowing down the frame rate. - Solution: I added a global variable that increases every time an enemy spawns. I then compare that number to the specified number of max allowed enemies to ensure the total number of spawned enemies doesn't go above that limit. When an enemy dies, that global variable decreases. - Problem: The health pickups were added later into the project and I needed a way to give them a spawn interval. At first, I thought about adding in another interval parameter to the StartWave function, but that was going to be more work than I had time for. - Solution: To the timer function I added a simple if/else statement that used a new interval variable to control the spawn delay. This variable is checked against a specified interval number to see if it's OK to spawn a pickup. This statement is housed within the timer loop so that it runs each second. - Problem: Near the beginning of the project, I was attempting to use the default group spawn script to control the spawning of my enemies. This presented the issue of lack of customization and control over each individual enemy wave. - Solution: I decided to bite the bullet and create my own custom wave spawner script. This would allow me to more freely use custom enemies, which would end up being necessary, as well as make it easier to incorporate the custom arena randomizer, health pickup, and timer functions. - Problem: Later in development, I started having an issue where enemies would sometimes not despawn at the end of a wave, which meant they would still be there if the player ended up in that arena again, making it more difficult to survive that wave. - Solution: Instead of using an array to store the enemies, I experimented with simply adding them to a reference collection alias instead. This ended up working better than the array, and was also a much simpler way of handling enemy removal. This also made it easier to remove enemies whenever the player died.
FakePlayerDeath Script
This script was a last-resort solution to create a roguelite experience by preventing player death to avoid automatic save reloads. After multiple failed attempts, this approach successfully worked. The above script snippet handles attacks from one enemy type, with similar code duplicated for any damage source the player could encounter in the arena.
Problems & Solutions
- Problem: I had to figure out how to simulate player death without the player actually dying. At first I tried setting the player to essential, but that resulted in a broken state where the player couldn't do anything since the 3rd person death camera still activated once the player's health reached 0. Then I tried using immortal mode. This came with two drawbacks. First, I would have to tell players to activate immortal mode before entering the arena, which ruined immersion. Secondly, and much worse, is that immortal mode didn't always allow the player's health to reach 0 or 1. Instead, the player simply wouldn't take any damage at all if they're health would end up at 0 or less. This resulted in situations where the player would have a fairly large amount of health left, but fake death would never trigger since all enemies would only ever deal more damage than the amount of health the player had left. - Solution: This custom script was my overall solution. I register for OnHit events on the player whenever the player takes damage. These OnHit events then run through a formula I created that determines how much damage to deal to the player based on the type of enemy, the enemy's level, the player's current damage resistance, and the player's current health. This formula doesn't allow the player's health to go below 1, but will make it so the player's health always reaches 1 after taking enough damage. Once the player's health reaches 1, they're fully healed and take back to a safe room to simulate death and respawning. - Problem: I couldn't use any of the enemies from the base game, from leveled lists or otherwise, due to how many various factors were involved in the damage they deal. In order for my custom player death solution to work, I had to be able to control all incoming damage. - Solution: This meant I had to create custom enemies as well as custom weapons to give them. I set the damage of all of the weapons to 1 and then set up variables in the script to control how much damage each one would deal. - Problem: An issue that arose from player feedback is that real death was still occurring somehow. This ended up being due to a legendary enchantment on some armor that higher level enemies could randomly have. Also, the boss dealt bleed damage from a legendary mod on his sword. - Solution: At first I looked into giving all enemies basic spacesuits with no mods. However, I quickly realized this would be more work than I had anticipated. I also liked how some enemies would have random spacesuit variations. Therefore, I figured out how to work the spell from that enchantment into my script so I could account for that as well. As for the bleed damage, I simply removed that legendary mod from the specific weapon since it was a custom weapon for the boss.
StartButton Script
A custom script was needed for the start button to handle different quest states, especially since most stages could run more than once. This script allows the button to restart the quest if the player dies, or start a wave depending on the current quest stage.
Problems & Solutions
- Problem: I wanted player's to have a slightly different experience depending on when they died. This called for a unique quest setup that had stages for different player death situations. The player would get a slightly different outcome if the first time they died was during the first wave, during any other wave, or if it was beyond the first death. This was complicated to set up, especially with the start button needing to know which quest stages to set. - Solution: I ended up using multiple global variables to run checks on in order to know which stages to set. These variables would change via other means whenever the player died or completed certain quest stages. - Problem: Players could break the quest and enemy waves by activating the button multiple times in a row, or by pressing it when it wasn't the right quest stage to press it. - Solution: I had to find multiple areas to place activation blocker scripts to control when the player is allowed to press the button. There are also objectives to let the player know when they can press it.
MoveWeapon Script
I created this script to gracefully unequip (drop) the player's weapon after each wave, or after dying. Once the weapon is dropped, it is moved to its starting table. This allows mods to be attached when upgrades requiring them are purchased.
Problems & Solutions
- Problem: A major hurdle I ran into halfway through development was when I realized any upgrades purchased that required mods to be attached to the player's weapon would get removed from the weapon every time this script forced the weapon to drop and move to the starting table. - Solution: Though I never figured out why this was happening, I did figure out a way to get around it. Using a global variable for each mod upgrade, I would check to see if an upgrade had been purchased in the past. If the mod had been purchased, the variable would change, which tells this script to reattach that mod when the weapon gets removed from the player. - Problem: Since I'm forcing the player's weapon to drop, it caused a bug where the player's hands would still be drawn and look as if they're holding a weapon. - Solution: Adding a line in the script that forces the player to sheathe their "weapon" ended up fixing this.
Other Scripts
TakePlayerEquipment: From the very beginning, I knew I wanted to remove all of the player's equipment so that they couldn't use it in the arena. This served two purposes. For one, I wanted this to feel as much like a whole new game inside the game as much as possible, which meant making the player feel like they're starting over in a sense. Secondly, it would simply make everything far easier to balance. The player's equipment is then given back at the end.
EnemyDeathActions: As mentioned for a previous script, in order to control how many enemies are spawned at any one time I needed to use a global variable. To make sure that variable remained accurate, I needed to subtract from it whenever an enemy died. I also needed to grant the player an upgrade point every time they killed an enemy. This script is where those functions are handled.
HealthPickUp: I created a unique health pickup that spawns randomly in the arena to give players more of a fighting chance. This script on that item is where I cast a healing spell on the player when the item is touched. It also controls the disabling of the object along with the starborn shader effect for it.
Other Design Features
Upgrade Kiosk
Overview
Problems & Solutions
- Problem: One of the main issues I had to get around was figuring out how to stop showing upgrades the player had already purchased. I didn't want those to show up since it would quickly bloat the menu making it frustrating to find upgrades not yet purchased - Solution: I figured out I could conditionalize the visibility of each menu item based on the value of a global variable. This meant I simply had to increment a variable for each upgrade whenever one was purchased, and then show the next rank of that upgrade based on the incremented variable. I used a second variable, the one controlling how many upgrade points the player currently had, to either allow the purchase of an upgrade or show a message about not having enough points. - Problem: During development, I came up with a point sink to help balance out player point bloat with the overall difficulty curve. What I decided on was a selection in the kiosk that would heal a percentage of the player's health at the cost of 1 point. The issue with this was that selecting this option, which immediately cast a healing spell on the player, put the player in a partial control lock. - Solution: After attempting to fix this by changing what menu the option appeared on as well as using a perk instead of a spell, the same issue would occur. At this point, I no longer had the time left to come up with a viable solution, so the decision was made to cut the feature in favor of balancing point bloat with slightly increased upgrade costs and adjusting enemy spawn intervals.
Health Pickup
I knew from an early stage that I wanted some kind of health pick-up to spawn in the arena for more of an arcade-y feel. The med packs in the game would be the obvious choice as player's would immediately know they're for healing in some way.
Problems & Solutions
- Problem: Normal med packs wouldn't work for a healing item because the player isn't allowed to access their inventory while in the arena, and unless they just happened to have base med packs favorited as a quick access item, they wouldn't be able to use them after picking them up. - Solution: I figured out a way to create an activator that used the med pack model, which would cast a healing spell on the player when the player touched it via trigger volume. This also lent itself better to the fast-paced nature of the wave battles since the player didn't need to slow down in order to manage healing themselves. - Problem: The base med packs are too small to be easily seen placed around the map during fast-paced combat. - Solution: To help alleviate this, I enlarged the med pack model and applied both an up and down floating animation, as well as a spinning animation, to it via animation helpers.
Community Feedback
"Amazing, mod and quest sequence fully functional. Great job."
"I think the mod is pretty great, really."