Vampire Survivors Tutorial Part 26

Creating a Rogue-like (like Vampire Survivors) in Unity — Part 26: Coins and Save System

This article is a part of the series:
Creating a Rogue-like Shoot 'Em Up (like Vampire Survivors) in Unity

This article will be free until we release the video for this part.

In the next part of our tutorial series, we will be implementing a save functionality for our coins and coin pickups. We’ll be introducing a coin system that persists across play sessions, allowing players to track their progression. Alongside this, we’ll add new pickups that reward players with coins during gameplay.

Currently, there are no persistent rewards tied to completing levels. By introducing collectible and usable coins, we create a more engaging experience where players feel rewarded for their efforts and have meaningful goals to work toward.

  1. Adding Coin Display in the title screen
    1. Adding the Coin Display UI
    2. Adding Coin Display Text
  2. Adding Coin Display in levels
    1. Setting up UICoinDisplay.cs script
  3. Adding a basic save functionality
    1. Creating the SaveManager.cs script
    2. Explaining the SaveManager script
    3. Future Expansion
  4. Updating the PlayerCollector.cs script
    1. Explanation of the new PlayerCollector changes
  5. Adding new Coin pickups
    1. Making the CoinPickup.cs script
    2. Explanation of the CoinPickup script
    3. Making Coin Pickups Prefabs
  6. Making the UICoinDisplay script
  7. Conclusion

1. Adding Coin Display in the title screen

We will first add a coin display to the title screen, similar to what’s shown in Vampire Survivors.

Vampire Survivors Homescreen

a. Adding the Coin Display UI

Let’s start by going to our title screen. Then in the Hierarchy go to the Canvas. This is where we will add the CoinDisplay UI, right click the Canvas > UI > Image.

Creating Ui Image in Title Screen

Next rename the UI element to CoinDisplay. Then, in the Rect Transform component, move your object to wherever on the screen you want to place it. I put it at (0, 100).

Next, in the Image component, set the Source Image to your preferred coin icon or sprite for the coin display.

Setting Rect Transform position and Image Sprite

Next set the Anchor of the text element. Split the anchor so it aligns properly with the coin icon—this ensures the text stays in the correct position across different screen sizes. You can reference this video to learn more about the technicalities of splitting anchors to position UI elements correctly and adaptively (i.e. making them look good on multiple different screen resolutions).

Your Coin UI Display should look like this once it’s set up correctly:

What the Coin Display looks like after setting up

If you’d like to use the same sprite as shown in our example, you can save the image below:

Coin Sprite

💡 Reminder: When creating the sprite for the Coin UI Display, do not include the coin number in the sprite itself—the number will be displayed using a separate UI element, allowing it to update dynamically based on the player’s coin count.

b. Adding Coin Display Text

Now it’s time to add the text that will display the coin count. Go to the CoinDisplay UI element we added earlier, then right-click on it in the Hierarchy and select UI > Text – TextMeshPro to create a new text element as its child. This text will be used to dynamically show the player’s current coin total.

Creating coin text display

Next rename the Text (TMP) to CoinDisplay Text. Then, in the Rect Transform component:

  • Set Pos Y to 70
  • Set Pos X to 5
  • Set Width to 200
  • Set Height to 50

Next, select the TextMeshPro – Text (UI) component, go to the Alignment section, and set the alignment to Right so that the coin count lines up neatly next to the coin icon.

Setting the Rect Transform position and alignment to right

Remember to split the anchor for the Coin Text. Make it something close to a 20/80 split. This is so that it will work with any screen size. Afterwards set it to auto size.

Splitting anchor for coin text

Your Coin UI Display should now look like this once the text is set up:

Coin Display With Text

2. Adding Coin Display in levels

Now we’ll be adding the CoinDisplay UI into the levels. This will allow players to see how many coins they’ve collected during the current run while playing the level. For this example, we’ll use the Level 1 – Mad Forest scene.

In the Hierarchy, right-click on the Canvas, then go to UI > Text – TextMeshPro to create a new text UI element. This text will be used to display the number of coins collected during the current run.

Creating coin display text for level 1

After creating the text name rename it to CoinDisplay Text. Then, in the Rect Transform component:

  • Set Pos Y to 835
  • Set Pos X to 490
  • Set Width to 220
  • Set height to 50

Next, select the TextMeshPro – Text (UI) component, go to the Alignment section, and set the alignment to Right so that the coin count lines up neatly next to the coin icon.

Setting the Rect Transform position and alignment to right

Remember to split the anchor like this:

Coin text anchor split

a. Setting up UICoinDisplay.cs script

Now it’s time to attach the UICoinDisplay.cs script to the CoinDisplay Text UI element we just created. This will enable the text UI to automatically update and show the correct number of coins collected during gameplay.

Then, in the Player GameObject, locate the Collector component—this is the reference we need for the collector variable in UICoinDisplay.cs.

Next, select the CoinDisplay text object with the UICoinDisplay.cs script attached. In the Inspector, you’ll see a field named Collector. Simply drag the Collector GameObject into this field to assign the reference.

setting of the collector variable

The PlayerCollector.cs is where we store the number of coins collected during the current game session. That’s why we assign it as a reference in the UICoinDisplay.cs script—so the script can access that coin data and display it accurately on the UI during gameplay.

3. Adding a basic save functionality

Now it’s time to add the script that will handle saving and loading the total coins collected across play sessions.

a. Creating the SaveManager.cs script

First, create a new C# script in your project and name it SaveManager.cs. This script will manage reading from and writing to a save file, ensuring the player’s coin progress is preserved between game sessions.

SaveManager.cs

using System.Collections;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using UnityEngine;

/// <summary>
/// A simple SaveManager designed to save the total number of coins the player has.
/// In later parts, this will be used to store all the player's save data, but we are
/// keeping it simple for now.
/// </summary>
public class SaveManager
{
    public class GameData
    {
        public float coins;
    }

    const string SAVE_FILE_NAME = "SaveData.json";

    static GameData lastLoadedGameData;
    public static GameData LastLoadedGameData
    {
        get
        {
            if (lastLoadedGameData == null) Load();
            return lastLoadedGameData;
        }
    }

    public static string GetSavePath()
    {
        return string.Format("{0}/{1}", Application.persistentDataPath, SAVE_FILE_NAME);
    }

    // This function, when called without an argument, will save into the last loaded
    // game file (this is how you should be calling Save() 99% of the time.
    // But you can optionally also provide an argument to it to if you want to overwrite the save completely.
    public static void Save(GameData data = null)
    {
        // Ensures that the save always works.
        if (data == null)
        {
            // If there is no last loaded game, we load the game to populate
            // lastLoadedGameData first, then we save.
            if (lastLoadedGameData == null) Load();
            data = lastLoadedGameData;
        }
        File.WriteAllText(GetSavePath(), JsonUtility.ToJson(data));
    }

    public static GameData Load(bool usePreviousLoadIfAvailable = false)
    {
        // usePreviousLoadIfAvailable is meant to speed up load calls,
        // since we don't need to read the save file every time we want to access data.
        if (usePreviousLoadIfAvailable) return lastLoadedGameData;

        // Retrieve the load in the hard drive.
        string path = GetSavePath();
        if (File.Exists(path))
        {
            string json = File.ReadAllText(path);
            lastLoadedGameData = JsonUtility.FromJson<GameData>(json);
        }
        else
        {
            lastLoadedGameData = new GameData();
        }
        return lastLoadedGameData;
    }
}

The SaveManager class is a static utility class designed to handle saving and loading game data in a Unity project. It currently focuses on storing the player’s coin count, but we can easily expand it for additional save data in the future.

b. Explaining the SaveManager script

The most important part of the SaveManager script is arguably the nested GameData class within:

public class SaveManager
{
    public class GameData
    {
        public float coins;
    }
    ...

This class acts as a container for all the data you want to save and load. Right now, it only holds a single float value for coins, but it’s designed to be easily extendable. As your game grows, you can add more fields—like player health, inventory, or settings—directly into the GameData class. This makes it simple to manage all your saved information in one place.

Managing the lastLoadedGameData

const string SAVE_FILE_NAME = "SaveData.json";
static GameData lastLoadedGameData;
  • SAVE_FILE_NAME: This constant defines the name of the file where your game’s save data will be stored. In this case, it’s "SaveData.json", which will be created in Unity’s persistent data path.
  • lastLoadedGameData: This static variable holds the most recently loaded save data in memory. By caching it, you avoid repeatedly reading from disk, which improves performance and simplifies data access during gameplay.

We also declare a getter for our lastLoadedGameData, to make it easier to access. If the variable is not set, we will automatically call the Load() function on it to retrieve it. This guarantees that any time you access this property, you’re working with valid, initialized save data—no need to manually call Load() elsewhere in your code.

public static GameData LastLoadedGameData
{
    get
    {
        if (lastLoadedGameData == null) Load();
        return lastLoadedGameData;
    }
}

The GetSavePath() function

A short function that assembles the path (i.e. folder) where the save file will be stored on the user’s computer. This function saves us the work of creating the save path string whenever we need to use it.

public static string GetSavePath()
{
    return string.Format("{0}/{1}", Application.persistentDataPath, SAVE_FILE_NAME);
}
  • Application.persistentDataPath: A Unity-provided directory that safely stores data between sessions, and works across all platforms.
  • Benefit: Ensures your save file is placed in a reliable, platform-independent location—whether on Windows, macOS, Android, iOS, etc.

The Save() function

Very simply, the Save() function writes the current game data into the location provided by GetSavePath(), so that our game is saved.

public static void Save(GameData data = null)
{
    if (data == null)
    {
        if (lastLoadedGameData == null) Load();
        data = lastLoadedGameData;
    }
    File.WriteAllText(GetSavePath(), JsonUtility.ToJson(data));
}

Here’s a breakdown of how it works:

(GameData data = null)
  • This means you can call Save() without passing anything in.
  • If you do pass a GameData object, the function will use that data to overwrite the current save.
  • If you don’t pass anything, it defaults to saving the game’s most recently loaded data (called lastLoadedGameData).
if (data == null)
{
    if (lastLoadedGameData == null) Load();
    data = lastLoadedGameData;
}
  • If no data is provided and nothing has been loaded yet, it tries to load the existing save file first.
  • This ensures that there’s always some data to save, so the function won’t fail.
Unity’s JsonUtility turns the GameData object into a JSON-formatted string.
  • Unity’s JsonUtility turns the GameData object into a JSON-formatted string.
  • JSON is a text-based format that’s easy to save, load, and read.
File.WriteAllText(GetSavePath(), JsonUtility.ToJson(data));
  • This writes the JSON string to the save file located at the path returned by GetSavePath().

The Load() function

The opposite of Save(), this function retrieves the file from GetSavePath() and converts it into a GameData object that we can retrieve data from.

public static GameData Load(bool usePreviousLoadIfAvailable = false)
{
    // usePreviousLoadIfAvailable is meant to speed up load calls,
    // since we don't need to read the save file every time we want to access data.
    if (usePreviousLoadIfAvailable) return lastLoadedGameData;
    // Retrieve the load in the hard drive.
    string path = GetSavePath();
    if (File.Exists(path))
    {
        string json = File.ReadAllText(path);
        lastLoadedGameData = JsonUtility.FromJson<GameData>(json);
    }
    else
    {
        lastLoadedGameData = new GameData();
    }
    return lastLoadedGameData;
}

Here’s a breakdown of how the function works:

public static GameData Load(bool usePreviousLoadIfAvailable = false)

This function is responsible for loading saved game data from a file, or creating new default data if no save file exists.

if (usePreviousLoadIfAvailable) return lastLoadedGameData;
  • This is a performance optimization.
  • If you pass in true and the game has already loaded data before, it just returns the cached data (lastLoadedGameData) instead of reading the file again.
  • This is useful when you want to access save data often but don’t want to repeatedly hit the disk, which can be slow.
string path = GetSavePath();

This gets the file path where your save data is stored on the user’s device.

if (File.Exists(path))
{
string json = File.ReadAllText(path);
lastLoadedGameData = JsonUtility.FromJson(json);
}

If the file exists, it:

  • Reads the contents of the file (which is a JSON string).
  • Uses Unity’s JsonUtility.FromJson to convert the JSON string back into a GameData object.
  • Stores the result in lastLoadedGameData for future use.
else
{
lastLoadedGameData = new GameData();
}

If there’s no existing save file, it creates a new empty GameData object with default values (e.g., coins = 0), and caches it.

return lastLoadedGameData;

Whether it loaded from file or created a new one, it returns the data to the caller.

c. Future Expansion

This SaveManager.cs is designed to be easily expanded in the future. To store additional player data, we can add new fields to the GameData class, as shown below (just an example):

public class GameData
{
    public float coins;
    //example expansions
    public int levelsCompleted;
    public List<string> unlockedItems = new List<string>();
    public Dictionary<string, bool> achievements = new Dictionary<string, bool>();
    public PlayerSettings settings = new PlayerSettings();
} 

The existing Save() and Load() methods will automatically handle the expanded data structure without requiring changes to their implementation.

4. Updating the PlayerCollector.cs script

PlayerCollector.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(Collider2D))]
public class PlayerCollector : MonoBehaviour
{
    PlayerStats player;
    CircleCollider2D detector;
    public float pullSpeed = 10;
    public UICoinDisplay UI;

    float coins;

    void Start()
    {
        player = GetComponentInParent<PlayerStats>();
    }

    public void SetRadius(float r)
    {
        if (!detector) detector = GetComponent<CircleCollider2D>();
        detector.radius = r;
    }

    public float GetCoins() { return coins; }
    //Updates coins Display and information
    public float AddCoins(float amount)
    {
        coins += amount;
        if (UI) UI.UpdateDisplay();
        return coins;
    }

    // Saves the collected coins to the save file.
    public void SaveCoinsToStash()
   {
        SaveManager.LastLoadedGameData.coins += coins;
        coins = 0;
        SaveManager.Save();
    }

    private void OnTriggerEnter2D(Collider2D col)
    {
        if (col.TryGetComponent(out Pickup p))
        {
            p.Collect(player, pullSpeed);
        }
    }
}

a. Explanation of the new PlayerCollector changes

public UICoinDisplay UI;
float coins;

A new variable called coins has been added. This variable is responsible for storing the number of coins collected during the current game session. It keeps track of temporary coin progress, which can later be saved to the player’s total coin stash using the SaveCoinsToStash() method and resetting its value.

Another variable called UI is also added in order to send coin information to the CoinDisplay UI in the scene. This allows the UI to update and reflect the current number of coins the player has collected in real time.

The GetCoins() function

public float GetCoins() { return coins; }

The GetCoins() method is a public getter that simply returns the current number of coins the player has collected during the current game session.

The AddCoins() function

 public float AddCoins(float amount)
 {
     coins += amount;
     if (UI) UI.UpdateDisplay();
     return coins;
 }

When this function is called, it will:

  • Add the given amount to the coins variable, updating the total coins collected in the current session.
  • Check if the UICoinDisplay is set (i.e., not null). If it is, it will update the UI display to reflect the new coin total.
  • Return the updated coin value, allowing other scripts to use or display the new total if needed.

The SaveCoinsToStash() function

 public void SaveCoinsToStash()
 {
     SaveManager.LastLoadedGameData.coins += coins;
     coins = 0;
     SaveManager.Save();
 }

Designed to be called whenever we want to save the total number of coins the player has, when this function is called, it will:

  1. Add the current session’s coin total (coins) to the player’s overall saved coins (SaveManager.LastLoadedGameData.coins).
  2. Reset the session coin counter by setting coins = 0, since the coins have now been transferred to the saved total.
  3. Call SaveManager.Save() to write the updated coin data to the save file, ensuring progress is preserved across sessions.

💡 Note: When writing scripts like this, the order in which code is written is very important, because in C# (and most programming languages), code is executed from top to bottom. For example, calling Save() before adding the session coins would save the wrong amount. Always update values first.

Now, go back to the scene and in the Inspector, navigate to Player > Collector. There, set the UI variable to the CoinDisplay Text we created earlier. This links the PlayerCollector to the UICoinDisplay, ensuring the UI updates when coins are collected.

Setting UI Variable to CoinDisplay Text

b. Assigning the SaveCoinsToStash() function

You might have noticed that this function isn’t being called anywhere in the code yet. That’s because we only want to save the coins when the game ends.

So, we’ll call this function only when the player clicks the Quit button on the Pause Screen or the Done button on the Result Screen. This ensures coins are only saved after a full session, avoiding premature or unintended saves.

Start by going to Canvas > Pause Screen > Quit then in the inspector go to Button and on the OnClick(), Add a new function to the list.

Adding Save function to Quit Button

Drag the Player > Collector object into the target variable field.

Dragging Collecotr to variable

Then, set the function to SaveCoinsToStash() by selecting:
PlayerCollector > SaveCoinsToStash() from the dropdown menu in the Unity Inspector event settings.

Setting the function of the button

This will ensure the coin-saving function is triggered when the assigned UI event (like a button click) occurs. Now do the same steps with all other buttons that changes the scene.

5. Adding new Coin pickups

We are going to add three new coin pickups to the game to give players a way to earn coins during gameplay. The pickups we’ll introduce are:

  • Gold Coin – awards 1 coin
  • Coin Bag – awards 10 coins
  • Rich Coin Bag – awards 100 coins

These will provide varying levels of coin rewards, making gameplay more dynamic and giving players satisfying moments when collecting higher-value pickups.

Coins we are adding to Vampire survivors

a. Making the CoinPickup.cs script

Now, to create the new coin pickups, we’ll need a script that inherits from the Pickup.cs class.

We’re subclassing Pickup instead of modifying it directly because only coin pickups need a reference to the PlayerCollector.cs component, whereas other pickups does not. There’s no point making every pickup call GetComponent() for the collector when most won’t use it. This keeps the base class clean and the coin logic isolated.

Start by creating a new script in your project and name it CoinPickup.cs. This script will define the behavior of each coin pickup, such as how many coins it gives when collected.

CoinPickup.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CoinPickup : Pickup
{
    PlayerCollector collector;
    public int coins = 1;

    protected override void OnDestroy()
    {
        base.OnDestroy();
        // Retrieve the PlayerCollector component from the player who picked up this object
        // Add coins to their total.
        if (target != null)
        {
            collector = target.GetComponentInChildren<PlayerCollector>();
            if (collector != null) collector.AddCoins(coins);

        }
    }
}

b. Explanation of the CoinPickup script

The CoinPickup script is a simple script that simply adds coins to the player. The reason we create a new script for it (instead of just adding a new coins variable to the Pickup class) is because it requires access to the player’s PlayerCollector component (whereas the Pickup script only has access to the PlayerStats component).

PlayerCollector collector;
public int coins = 1;

Hence, why we have the 2 variables that are declared in this script. The reference to the PlayerCollector component allows us to call AddCoins() to send coins to it.

The second variable, coins, is used to store the amount of coins this pickup should give when collected. This allows different pickups (like Gold Coin, Coin Bag, or Rich Coin Bag) to award different coin values.

Initially, we actually placed the coin tracking functionality in GameManager before later deciding to code it into PlayerCollector. The reason for letting PlayerCollector handle this was to make it easier for us to support multiplayer in the future, since we can have multiple players each managing their own coin total independently.

OnDestroy() function

protected override void OnDestroy()
{
    base.OnDestroy();
    // Retrieve the PlayerCollector component from the player who picked up this object
    // Add coins to their total.
    if (target != null)
    {
        collector = target.GetComponentInChildren<PlayerCollector>();
        if (collector != null) collector.AddCoins(coins);
    }
}

When this function is called, it will:

  1. Call the base OnDestroy() method from the parent Pickup class to ensure any inherited cleanup logic is executed.
  2. Check if target is not null—if it isn’t, it sets collector to the PlayerCollector component of the player who picked up the item.
  3. Check if collector is not null—if valid, it calls the AddCoins() function on the collector to add the coins to that player’s session total.

c. Making Coin Pickups Prefabs

In the Project folder, go to the Prefabs folder > Pick-ups, and duplicate one of the existing pickups, such as the Blue Gem.

  • In the Inspector of the duplicate, change the Sprite Renderer sprite to your desired sprite for the coin pickup.
  • Then, replace the PickUp.cs script with the newly created CoinPickup.cs script.
  • Finally, set the Coins variable in the Inspector to the amount of coins you want the pickup to give (e.g., 1 for Gold Coin, 10 for Coin Bag, etc.).
Setting up the Coin Pick-ups prefab

Now the pickups should be fully working and ready for use. Try dragging them into the scene and giving them a test run to ensure that they correctly award coins when collected. If everything is set up properly, you should see the coin amount update in the UI when the player picks them up.

Coin Prefabs

If you’d like to use the same sprite as shown in our example, you can download it here:

6. Making the UICoinDisplay script

Now, we will create a script that will help us display the player’s coin count of both the title screen and levels.

UICoinDisplay.cs

Be reminded that:

  1. On the title screen, the coins displayed should be the total number of coins the player has collected.
  2. In-game, the coins displayed should be the total number of coins the player has collected in the game itself.

Hence, the script will be designed in such a way that every instance of it can be configured to display either the coins collected in the current game, or the grand total number of coins collected.

UIDisplay without collector variable
With the collector variable empty it will display the overall coins collected by the player
UIDisplay with collector variable
When the collector variable is set, it will display the number of coins collected by the player during the game

Next After making the UICoinDisplay.cs script, attach it to the UI element CoinDisplay made previously.

Attaching the UICoinDisplay Script to CoinDisplay UI Element

💡 Reminder: Make sure the collector variable is not set when using this script on menus like the title screen—this will ensure it displays the total coins collected across all sessions instead of just the current game’s coins.

7. Conclusion

After following all the steps and setting the necessary variables, the Save function and Coin system should now be fully working!

You can test it by collecting coins in-game, saving the progress, and checking that the total coin count is retained between sessions. And that’s it for this part!

Silver Patrons and above can download the project files.