Key Features
- 20+ upgradeable weapons and abilities
- 4 unique enemy types and 2 bosses
- Object pools for monsters, projectiles, items, chests, damage text, etc.
- Infinite background shader
- Mobile, gamepad, and keyboard input support
- Enemy spawn chance/rate keyframe system
- ScriptableObjects for straightforward character and level design
- Level up system
- Mandarin localization
Description
During a 3 month internship at Gamania in Taiwan I was asked to create a mobile game inspired by one of the most popular indie games of the year, Vampire Survivors.
More specifically, I was tasked with creating a game in which the player fights off hordes of increasingly difficult monsters using a collection of random, automatically attacking, upgradeable weapons and abilities. This involved creating a basic player controller, a level up system, various enemy types, and a whole bunch of unique items and abilities for the player to use.
The game is split into quick, 10 minute levels that are completed by surviving to the end of the level and defeating the final boss.
Challenges
During the development of this game I faced a number of interesting challenges.
Infinite Background
The first challenge I encountered was that of creating an infinite background for the player to walk across.
A classic approach to this problem would be to place several large, seamless tiles around the player's position and then as soon as the player nears the edge of one tile, bring a tile that had already moved entirely offscreen on the opposite side to the side in front of the player, thereby reusing tiles to give the illusion of an infinite background. While simple to implement, I chose not take this approach as it failed to meet a self-imposed requirement of mine: create an infinite background without the appearance of obviously repeating tiles.
Knowing that I wanted to remove any evidence of repeating tiles, for my actual solution to this problem I began by implementing a custom non-repeating texture shader, as discussed here. This allowed me to use any texture I wanted for the background image, seamless or not, without any obvious signs of repetition.
To give the illusion of the player walking across an infinite background, I fixed a screen-sized quad with this custom shader applied to the player's position so that they moved together in tandem. I then made a slight modification to the shader so that instead of doing background texture samples based on preset vertex UVs, each fragment sampled the background texture based on its world position. This meant that as the player moved throughout the scene the coordinates used for background texture lookups changed based on the world position of the player. Just like that, this gave the appearance that the player was walking over an infinite, non-repeating background.
One issue with this approach is that as the player gets farther and farther from the origin, floating point imprecision causes artifacts to appear when sampling the texture since the lookup coordinates are derived from the player's world position.
While it is rare that the player ever runs far enough away from the origin to encounter this issue, I made an attempt to combat it nevertheless.
Once the player has walked far enough away from the origin, an offset is applied to the world space position of each fragment inside the shader so that the texture lookup coordinates are once again near the origin, and the player's current position is saved as a new "origin." If the player walks far enough away from this new origin, this process will repeat itself. Of course, applying an instant offset to the texture lookup coordinates would lead to an abrupt and confusing sensation of teleportation for the player. Instead, once the player has walked far enough away from the origin, the shader samples the background texture at both the current world position and the new offset world position, blending from one to the other over time such that it is effectively imperceptible to the player that the background is being reset back to a position near the origin.
Enemy Spawning / Level Balancing
A key challenge to ensuring levels are enjoyable for the player is balancing enemy spawn rates and the distribution of which enemy types are spawned over the course of the level.
At first I considered taking a numerical approach to this problem, perhaps estimating the player's expected DPS (damage per second) over time for a given level, and precomputing a corresponding variable spawn rate for the enemies. Or even better, computing those values in real-time and actively scaling the spawn rate and health of the enemies accordingly to ensure that the player is always facing a challenging yet winnable scenario. However, since a level should ideally play out the same from run to run, a real-time difficulty scaling approach did seem suitable, and as for the precomputed approach, such a model would also be difficult to tune as estimating the player's DPS given all of the potential combinations of items and abilities is not trivial — not to mention other confounding factors such as enemy DPS!
Knowing that balancing each level was going to require at least some playtesting no matter what, I instead opted for an approach that would allow me to efficiently iterate and test different enemy configurations and spawn rates. I did so using a simple keyframe based spawning system that works as follows:
- For a given level, the developer first specifies which enemies are allowed to spawn in that level.
- Then, the developer creates "spawn chance keyframes" at select times throughout level. Each keyframe specifies the spawn chance for each of enemy at that given point in time.
- Next, the developer does the same thing, but for "spawn rate keyframes," which specify the overall spawn rate of all the enemies at given points in time.
- Finally, when the level is played, the EnemySpawner script interpolates between keyframes to determine the exact spawn chance of every enemy at the current point in time, as well as the exact spawn rate, and spawns enemies accordingly.
I found this system to be extremely effective in allowing me to iteratively balance levels. For example, I was able to build and balance the first level of the game in less than half an hour using this system by simply setting up some preliminary keyframes that followed a rough outline for how I thought the level should play out, and then playing through the level a few times to tweak those values and add additional keyframes where needed.
Demo
Following are demonstrations of some of the core systems in the game.
Level Up
The player level ups by collecting XP gems dropped from slain enemies. When leveling up, the player is given a choice between three options consisting of new abilities as well as upgrades to abilities they already own.
Abilities
The game contains 20+ unique weapons and abilities.
Each ability has a likelihood of appearing in the level up screen based on its rarity, where rarity is generally correlated with how powerful the ability is.
Abilities are also upgradeable, meaning that once they have been acquired for the first time they have a chance of appearing in the level up screen again, giving the player the opportunity to further improve them.
A key challenge I encountered while developing the abilities was making them not only visually distinct, but also mechanically distinct from one another. For example, I was asked to add three projectile-based weapons to the game -- a bazooka, throwing stars, and a machine gun. Initially, I had all projectile-based abilities inheriting from the same "Projectile Ability" class that simply launched projectiles in the direction of the player's movement. Naturally, when more than one projectile-based ability was in use at the same time, this lead to a visually messy and very one-note gameplay experience. To resolve this specific issue, I gave each weapon its own distinct firing mode: the bazooka locks on to the closest enemy and launches an explosive projectile at it, whereas the machine gun orbits the player and fires outward in all directions, while the throwing star is simply launched in the direction the player is moving. In general, for any weapons of similar type (e.g. all melee weapons), time was taken to ensure that despite being similar, each weapon attacks slightly differently, or occupies a different part of the screen than its counterparts. This went a long way into making each ability feel unique.
Below are demonstrations of a handful of abilities from the game:
Not all abilities grant weapons. Instead, some abilities grant global upgrades which apply to all of the player's weapons/abilities. For example, there's an ability that increases the area of effect (AOE) of all abilities that have an AOE (such as bazooka, molotov, etc.), an ability that increases the damage of all damaging abilities, and most fun of all, an ability that increases the projectile count of all projectile-based weapons (including grenades, molotovs, axes and more).
In many ways, the heart of the game lies in using these global upgrades along with each ability's unique upgrade path to create exponentially powerful, game-breaking builds.
Enemies
There are currently 4 unique basic enemy types as well as 2 boss enemies in the game.
The basic enemy types are melee, ranged projectile, boomerang, and grenade throwing enemies, where each type includes enemies of varying strength, speed, health, etc.
Bosses are made up of a collection of modular abilities, such that new bosses can be quickly created by simply utilizing a different combination of pre-existing boss abilities. The three abilities that have been created so far are the charge ability, the shotgun ability, and the grenade throw ability. The boss chooses which ability to use at any given time via the strategy design pattern, in which each ability is scored based on how useful it is to the boss at that moment, and the boss chooses the ability with the highest score.
Items
Another difference between this game and Vampire Survivors is the inclusion of items — such as healing potions, bombs, and magnets — which the player can activate using the corresponding button on the right hand side of the screen. These items are found in chests which appear randomly throughout the map during each level.
Game Over
The game ends in defeat when the player's health reaches zero, and in victory if the player manages to survive past the allotted time and defeat the final boss. The game over screen displays stats about the number of enemies killed, damage dealt, etc.
Tools Used
Languages: C#, HLSL
Software: Unity, Aseprite, Git, Creature Mixer