Vampire Survivors Part 27

Creating a Rogue-like (like Vampire Survivors) in Unity — Part 27: Treasure Chest UI Animation

Now that we’ve set up a coin collection system from the previous part, we’re now ready to improve upon the very simple treasure chest that we’ve implemented in Part 12 to faciliate weapon evolution. In this part, we’ll enhance the treasure chest system to more closely mirror the mechanics seen in Vampire Survivors.

Our upgrades will include the ability for chests to level up existing weapons and passives, grant entirely new ones, and reward players with multiple items at once—ranging from 1, 3, or even 5 rewards per chest.

  1. Supporting different chest types
    1. Creating asset menu
  2. Making UITreasureChest.cs script
    1. explanation of UITreasureChest.cs script
    2. Beams and itemdisplay
  3. Making TreasureChest Manager
    1. Making the chest panel
    2. Making the beams and rewards.
    3. Chest Opening Visual Effects
  4. Updating the PlayerInventory.cs script
    1. explanation of the changes to the PlayerInventory.cs script
  5. TreasureChest.cs script
    1. explanation of the TreasureChest.cs script
  6. Making the Treasure Chest Prefab

1. Supporting different chest types

In Vampire Survivors, there are many different chest types. Each of these chest types not only differ in the number of items, type of items, and number of coins that they drop—the same kind of chest can also have different kinds of drops, depending on your Luck stat.

5 Item Chest
3 Item Chest
1 item beam

To make it easy for us to configure these different chests, we will be using different prefabs for each type of chest. But because each chest can have different kinds of drop profiles, we will also be using a ScriptableObject to define how many items the chest will drop, coins to drop, along with settings related to the user interface for displaying those rewards.

TreasureChestDropProfile.cs

using UnityEngine;

[CreateAssetMenu(fileName = "Treasure Chest Drop Profile", menuName = "2D Top-down Rogue-like/Treasure Chest Drop Profile")]
public class TreasureChestDropProfile : ScriptableObject
{
    [Header("General Settings")]
    public string profileName = "Drop Profile";
    [Range(0, 1)] public float luckScaling = 0;
    [Range(0, 100)] public float baseDropChance;
    public float animDuration;

    [Header("Fireworks")]
    public bool hasFireworks = false;
    [Range(0f, 100f)] public float fireworksDelay = 1f;

    [Header("Item Display Settings")]
    public int noOfItems = 1;
    public Color[] beamColors = new Color[] { new Color(0, 0, 1, 0.6f) };

    [Range(0f, 100f)] public float delayTime = 0f;
    public int delayedBeams = 0;

    public bool hasCurvedBeams;
    public float curveBeamsSpawnTime;

    [Header("Coins")]
    public float maxCoins = 0;
    public float minCoins = 0;

    [Header("Chest Sound Effects")]
    public AudioClip openingSound;
}

a. Creating asset menu

After creating the script, navigate to Project > Scriptable Objects > TreasureChest. In that folder, right-click and select Create > 2D Top-down Rogue-like > Treasure Chest Drop Profile. This will create a new ScriptableObject asset. Rename it to 1-item.

Creating a scriptable object for the treasure chest drop profile

Click on the 1-item ScriptableObject, and you’ll see its properties displayed in the Inspector window, which should look something like this:

drop profile

In the Item Display Settings, set the Number of Items to 1. Then, under the Coins section, configure the reward range by setting Minimum Coins to 50 and Maximum Coins to 100. You can adjust these values later to better fit your game’s desired reward balance. Finally, set the Animation Duration to 7.25 seconds, and assign a chest opening sound of your choice to the Opening Sound field.

Once you’ve finished setting up the first drop profile, you can proceed to create additional drop profiles for the other treasure chests.

1 items settings
1-item treasure chest
3 items settings
3-item treasure chest
5 items settings
5-item treasure chest

2. Making UITreasureChest.cs script

We’ll create a dedicated script to manage the user interface that appears when a treasure chest is opened. This script will handle all visual feedback associated with the chest opening, including animations, particle effects, and sound. Additionally, it will be responsible for presenting the rewards to the player in a clear and engaging way.

UiTreasureChest.cs

using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;

public class UITreasureChest : MonoBehaviour
{

    public static UITreasureChest instance;
    PlayerCollector collector;
    TreasureChest currentChest;
    TreasureChestDropProfile dropProfile;

    [Header("Visual Elements")]
    public GameObject openingVFX;
    public GameObject beamVFX;
    public GameObject fireworks;
    public GameObject doneButton;
    public GameObject curvedBeams;
    public List<ItemDisplays> items;
    Color originalColor = new Color32(0x42, 0x41, 0x87, 255);

    [Header("UI Elements")]
    public GameObject chestCover;
    public GameObject chestButton;

    [Header("UI Components")]
    public Image chestPanel;
    public TextMeshProUGUI coinText;
    private float coins;

    // Internal states
    private List<Sprite> icons = new List<Sprite>();
    private bool isAnimating = false;
    private Coroutine chestSequenceCoroutine;

    //audio
    private AudioSource audiosource;
    public AudioClip pickUpSound;

    [System.Serializable]
    public struct ItemDisplays
    {
        public GameObject beam;
        public Image spriteImage;
        public GameObject sprite;
        public GameObject weaponBeam;
    }

    private void Update()
    {
        //only allow skipping of animation when animation is playing adn esc is pressed
        if (isAnimating && Input.GetButtonDown("Cancel"))
        {
            SkipToRewards();
        }

        if (Input.GetKeyDown(KeyCode.Return))
        {
            TryPressButton(chestButton);
            TryPressButton(doneButton);
        }
    }

    private void TryPressButton(GameObject buttonObj)
    {
        if (buttonObj.activeInHierarchy)
        {
            Button btn = buttonObj.GetComponent<Button>();
            if (btn != null && btn.interactable)
            {
                btn.onClick.Invoke();
            }
        }
    }

    private void Awake()
    {
        audiosource = GetComponent<AudioSource>();
        gameObject.SetActive(false);

        // Ensure only 1 instance can exist in the scene
        if (instance != null && instance != this)
        {
            Debug.LogWarning("More than 1 UI Treasure Chest is found. It has been deleted.");
            Destroy(gameObject);
            return;
        }

        instance = this;
    }

    public static void Activate(PlayerCollector collector, TreasureChest chest) {
        if(!instance) Debug.LogWarning("No treasure chest UI GameObject found.");

        // Save the important variables.
        instance.collector = collector;
        instance.currentChest = chest;
        instance.dropProfile = chest.GetNextDropProfile();
        
        // Activate the GameObject.
        GameManager.instance.ChangeState(GameManager.GameState.TreasureChest);
        instance.gameObject.SetActive(true);
    }

    // VFX logic
    public IEnumerator Open()
    {
        //Trigger if hasFireworks beam is true
        if (dropProfile.hasFireworks)
        {
            isAnimating = false; //if there are fireworks ensure it can't be skipped
            StartCoroutine(FlashWhite(chestPanel, 5)); // or whatever UI element you want to flash
            fireworks.SetActive(true);
            yield return new WaitForSecondsRealtime(dropProfile.fireworksDelay);
        }

        isAnimating = true; //allow skipping of animations

        //Trigger if hasCurved beam is true
        if (dropProfile.hasCurvedBeams)
        {
            StartCoroutine(ActivateCurvedBeams(dropProfile.curveBeamsSpawnTime));
        }

        // Set the coins to be received.
        StartCoroutine(HandleCoinDisplay(Random.Range(dropProfile.minCoins, dropProfile.maxCoins)));

        DisplayerBeam(dropProfile.noOfItems);
        openingVFX.SetActive(true);
        beamVFX.SetActive(true);

        yield return new WaitForSecondsRealtime(dropProfile.animDuration); //time VFX will be active
        openingVFX.SetActive(false);

    }

    IEnumerator ActivateCurvedBeams(float spawnTime)
    {
        yield return new WaitForSecondsRealtime(spawnTime);
        curvedBeams.SetActive(true);
    }

    // logic for chest to flash
    private IEnumerator FlashWhite(Image image, int times, float flashDuration = 0.2f)
    {
        originalColor = image.color;

        //flashes the chest panel for x amount of times
        for (int i = 0; i < times; i++)
        {
            image.color = Color.white;
            yield return new WaitForSecondsRealtime(flashDuration);

            image.color = originalColor;
            yield return new WaitForSecondsRealtime(0.2f);
        }
    }

    public void DisplayerBeam(float noOfBeams)
    {
        int delayedStartIndex = Mathf.Max(0, (int)noOfBeams - dropProfile.delayedBeams); //ensure beams do not go out of index

        // Show immediate beams
        for (int i = 0; i < delayedStartIndex; i++)
        {
            SetupBeam(i);
        }

        // Delay the rest
        if (dropProfile.delayedBeams > 0)
            StartCoroutine(ShowDelayedBeams(delayedStartIndex, (int)noOfBeams));

        StartCoroutine(DisplayItems(noOfBeams));
    }

    // Display beams
    private void SetupBeam(int index)
    {
        items[index].weaponBeam.SetActive(true);
        items[index].beam.SetActive(true);
        items[index].spriteImage.sprite = icons[index];
        items[index].beam.GetComponent<Image>().color = dropProfile.beamColors[index];
    }

    // Display delayed beams
    private IEnumerator ShowDelayedBeams(int startIndex, int endIndex)
    {
        yield return new WaitForSecondsRealtime(dropProfile.delayTime);

        for (int i = startIndex; i < endIndex; i++)
        {
            SetupBeam(i);
        }
    }

    private IEnumerator DisplayItems(float noOfBeams)
    {
        yield return new WaitForSecondsRealtime(dropProfile.animDuration);

        if (noOfBeams == 5)
        {
            // Show first item
            items[0].weaponBeam.SetActive(false);
            items[0].sprite.SetActive(true);
            yield return new WaitForSecondsRealtime(0.3f);

            // Show second and third at the same time
            for (int i = 1; i <= 2; i++)
            {
                items[i].weaponBeam.SetActive(false);
                items[i].sprite.SetActive(true);
            }
            yield return new WaitForSecondsRealtime(0.3f);

            // Show fourth and fifth at the same time
            for (int i = 3; i <= 4; i++)
            {
                items[i].weaponBeam.SetActive(false);
                items[i].sprite.SetActive(true);
            }
            yield return new WaitForSecondsRealtime(0.3f);
        }
        else
        {
            // Fallback for other item counts — show normally one by one
            for (int i = 0; i < noOfBeams; i++)
            {
                items[i].weaponBeam.SetActive(false);
                items[i].sprite.SetActive(true);
                yield return new WaitForSecondsRealtime(0.3f);
            }
        }
    }


    // Activates animations.
    public void Begin()
    {
        chestCover.SetActive(false);
        chestButton.SetActive(false);
        chestSequenceCoroutine = StartCoroutine(Open());
        audiosource.clip = dropProfile.openingSound;
        audiosource.Play();

    }

    //Give coins to player
    IEnumerator HandleCoinDisplay(float maxCoins)
    {
        coinText.gameObject.SetActive(true);
        float elapsedTime = 0;
        coins = maxCoins;

        //coin rolling up animation and will stop when it has reached maxcoins
        while (elapsedTime < maxCoins) 
        {
            elapsedTime += Time.unscaledDeltaTime * 20f;
            coinText.text = string.Format("{0:F2}", elapsedTime);
            yield return null;
        }
        
        //only activate the done button when coins reach max
        yield return new WaitForSecondsRealtime(2f);
        doneButton.SetActive(true);
    }

    public void CloseUI()
    {
        //Display Coins earned
        collector.AddCoins(coins);

        // Reset UI & VFX to initial state
        chestCover.SetActive(true);
        chestButton.SetActive(true);
        icons.Clear();
        beamVFX.SetActive(false);
        coinText.gameObject.SetActive(false);
        gameObject.SetActive(false);
        doneButton.SetActive(false);
        fireworks.SetActive(false);
        curvedBeams.SetActive(false);
        ResetDisplay();

        //reset audio
        audiosource.clip = pickUpSound;
        audiosource.time = 0f;
        audiosource.Play();

        isAnimating = false;

        GameManager.instance.ChangeState(GameManager.GameState.Gameplay);
    }

    // Display the icons of all the items received from the treasure chest.
    public static void NotifyItemReceived(Sprite icon)
    {
        // Includes a warning message informing the user of what the issue is if
        // we are unable to update this class with the icon.
        if(instance) instance.icons.Add(icon);
        else Debug.LogWarning("No instance of UITreasureChest exists. Unable to update treasure chest UI.");
    }

    // Reset the items display
    private void ResetDisplay()
    {
        foreach (var item in items)
        {
            item.beam.SetActive(false);
            item.sprite.SetActive(false);
            item.spriteImage.sprite = null;

        }
        dropProfile = null;
        icons.Clear();
    }

    private void SkipToRewards()
    {
        if (chestSequenceCoroutine != null)
            StopCoroutine(chestSequenceCoroutine);

        StopAllCoroutines(); // Halt all coroutines

        // Immediately show all beams and icons
        for (int i = 0; i < icons.Count; i++)
        {
            SetupBeam(i);
            items[i].weaponBeam.SetActive(false);
            items[i].sprite.SetActive(true);
        }

        // Immediately show coin value
        coinText.gameObject.SetActive(true);
        coinText.text = coins.ToString("F2");
        doneButton.SetActive(true);
        openingVFX.SetActive(false);
        isAnimating = false;
        chestPanel.color = originalColor;

        // Skip to the last 1 second of the audio
        if (audiosource != null && dropProfile.openingSound != null)
        {
            audiosource.clip = dropProfile.openingSound;

            float skipToTime = Mathf.Max(0, audiosource.clip.length - 3.55f); // Ensure it doesn't go below 0
            audiosource.time = skipToTime;
            audiosource.Play();

        }
    }


}

a. explanation of UITreasureChest.cs script

Most of the script focuses on activating and deactivating the appropriate objects and visual effects at the right moments during the chest opening sequence. So instead of diving into the routine parts, let’s walk through the key sections where you can customize your own effects. With some important logics in the chest.

Why create an instance of the TreasureChestUI.cs script?

This is because all treasure chests in the game will share a single UIManager to display rewards. To make it easy for any chest to access and communicate with the UIManager, we implement it as a singleton. This allows each chest to quickly reference the UIManager instance and pass the relevant data to it without needing direct object references or manual setup.

    public static void Activate(PlayerCollector collector, TreasureChest chest) {
        if(!instance) Debug.LogWarning("No treasure chest UI GameObject found.");

        // Save the important variables.
        instance.collector = collector;
        instance.currentChest = chest;
        instance.dropProfile = chest.GetNextDropProfile();
        
        // Activate the GameObject.
        GameManager.instance.ChangeState(GameManager.GameState.TreasureChest);
        instance.gameObject.SetActive(true);
    }

Next, we’ll create a function that allows a treasure chest to pass its data to the TreasureChestUI script. The key pieces of information we need are:

  • The PlayerCollector – to identify which player picked up the chest.
  • A reference to the chest itself – so we know which chest was opened.

Knowing who opened the chest is important because it allows us to credit the correct player with the coin reward. This approach also prepares the system for potential multiplayer support in the future, where rewards must be assigned accurately to individual players.

In addition to that, we’ll extract the DropProfile selected by the chest upon opening. This profile determines which rewards were chosen and allows the UI to display the correct visual effects and item information accordingly.

After transferring the chest data to the UI, we’ll change the game state to TreasureChest. This game state simply sets the Time.timeScale to 0, effectively pausing all gameplay activity.

b. Beams and itemdisplay

In vampire survivors the treasure chest will have beams that will be displayed when opened and the amount of beams will be according to the rewards amount.

How we are going to achieve this effect is we are going to place the beams in its position and rotation and activate the beams according to the rewards and change the colors accordingly. Hence why the drop profile has the color beam option

3-item treasure chest properties
3-item treasure chest
    public struct ItemDisplays
    {
        public GameObject beam;
        public Image spriteImage;
        public GameObject sprite;
        public GameObject weaponBeam;
    }

We use a struct here to group together all the visual components related to a single reward beam. Each reward display typically includes multiple elements:

  • the beam effect,
  • the item sprite,
  • a scrolling image component to show the item’s icon,
  • and a item-scrolling beam effect.

By storing these elements in a single ItemDisplays struct, we can manage them as a unified group. This makes it easier to activate, update, or animate the correct visual components for each reward item at the right time during the chest opening sequence. It also keeps the code more organized and avoids juggling separate arrays or lists for each element type.

DisplayerBeam(dropProfile.noOfItems);

in line 131 we will call this function Which will tell the function how many beams to display based on the the reward count.

public void DisplayerBeam(float noOfBeams)
{
    int delayedStartIndex = Mathf.Max(0, (int)noOfBeams - dropProfile.delayedBeams); //ensure beams do not go out of index

    // Show immediate beams
    for (int i = 0; i < delayedStartIndex; i++)
    {
        SetupBeam(i);
    }

    // Delay the rest
    if (dropProfile.delayedBeams > 0)
        StartCoroutine(ShowDelayedBeams(delayedStartIndex, (int)noOfBeams));

    StartCoroutine(DisplayItems(noOfBeams));
}

We’ll use a simple for loop based on the number of rewards to activate the corresponding beam effects. For each reward in the list, the loop will activate one beam. So, for example, if there are 3 rewards, the first 3 beams in the list will be activated to match the number of items being presented.

There’s also an option to set how many of the reward beams should be delayed. This is particularly used for the 5-item chest, where you’ll notice the last 2 beams appear slightly later than the first 3.

Next, we’ll focus on displaying the item sprites. We want to ensure that each reward is shown with the correct icon corresponding to the items given by the chest. To achieve this, we’ll create a function that allows the treasure chest to send the icon data to the TreasureChestUI. The UI manager will then store and use this data to visually present the correct items on screen.

 public static void NotifyItemReceived(Sprite icon)
 {
     // Includes a warning message informing the user of what the issue is if
     // we are unable to update this class with the icon.
     if(instance) instance.icons.Add(icon);
     else Debug.LogWarning("No instance of UITreasureChest exists. Unable to update treasure chest UI.");
 }

Whenever an item is pulled from the chest, it will send its icon information to this function. The function will then store the icon in a list or array—named icons—which will later be used to visually display the items during the reward sequence.

displaying the items are essentially the same as the beams where we will use a for loop to display the amount of items. The items display activation will have a delay as they are only displayed at the very end of the chest opening sequence.

  if (noOfBeams == 5)
  {
      // Show first item
      items[0].weaponBeam.SetActive(false);
      items[0].sprite.SetActive(true);
      yield return new WaitForSecondsRealtime(0.3f);

      // Show second and third at the same time
      for (int i = 1; i <= 2; i++)
      {
          items[i].weaponBeam.SetActive(false);
          items[i].sprite.SetActive(true);
      }
      yield return new WaitForSecondsRealtime(0.3f);

      // Show fourth and fifth at the same time
      for (int i = 3; i <= 4; i++)
      {
          items[i].weaponBeam.SetActive(false);
          items[i].sprite.SetActive(true);
      }
      yield return new WaitForSecondsRealtime(0.3f);
  }

⚠️ Inside the DisplayItem() function, this section is specifically designed to handle how items are displayed in the 5-item chest scenario. This part is hardcoded to replicate the dramatic reveal effect seen in Vampire Survivors. If your project doesn’t require this particular presentation style, or if you prefer a simpler display method, feel free to remove or modify this section to better suit your needs.

3. Making TreasureChest Manager

Now we’ll create the Treasure Chest Manager, which will handle all the item displays and visual effects.

  1. Start by creating a new Canvas in your scene and rename it to TreasureChestManager.
  2. In the Canvas component, set the Render Mode to Screen Space – Camera.
  3. Assign the Main Camera to the Render Camera field.
  4. Set sorting layer to foreground and Order in Layer to 100.
  5. In the Canvas Scaler, set the UI Scale Mode to Scale With Screen Size.
  6. Set the Reference Resolution to 1920 x 1080.
  7. Finally, assign the UITreasureChest.cs script to the TreasureChestManager object.
Setting Up Ui Treasure Chest

Why use Screen Space – Camera?

We use this render mode because we’ll be adding particle effects (like sparkles, beams, or shine animations) that need to appear layered above the UI. By rendering the UI through the camera, we can precisely control the sorting of effects and UI elements, ensuring everything displays as intended.


Why use Scale With Screen Size?

We use Scale With Screen Size to make sure the UI looks consistent across different screen resolutions. This setting scales the UI elements proportionally based on the screen’s dimensions relative to a reference resolution (in this case, 1920×1080).
Without this, the UI might appear too large or too small on screens with different sizes or aspect ratios, which could break layout or visual balance. This approach ensures that buttons, icons, and item displays maintain their intended size and spacing on all devices.

a. Making the chest panel

The Chest Panel is the main UI element that displays the treasure chest and related information. It will contain all the necessary visuals and interactive elements for the chest-opening experience.

The Chest Panel will include:

Chest Panel First Look
  • A background image to frame the chest display
  • A border to separate the panel from the rest of the UI
  • The chest sprite that visually represents the treasure chest
  • An “Open Chest” button to trigger the chest opening
  • A “Close Chest” button to exit the UI




Start by creating a UI Image under the TreasureChestManager and rename it to ChestPanel.
This panel will serve as the container for all the chest-related UI elements. By parenting everything under the ChestPanel, we can easily show or hide the entire chest UI simply by enabling or disabling this single object.

With the ChestPanel selected, go to the Inspector and locate the Image component.
Set its color to #4E28FF, or choose any color that fits your game’s visual theme. This color serves as the background for the chest interface.

Next, adjust the RectTransform settings:

  • Set Width to 350 and Height to 500 (or choose dimensions that suit your layout).
  • Set the Anchor Presets to the middle-center of the screen.
Chest Panel

Why anchor to the center instead of splitting the anchors?

We anchor the ChestPanel to the center because we want it to maintain a fixed size and consistent position—right in the middle of the screen—across different resolutions. If we were to split the anchors (e.g., stretch horizontally or vertically), the panel might scale in unpredictable ways depending on the screen size or aspect ratio. That could distort the layout, stretch the visuals, or misalign the content.

Center anchoring ensures:

  • The visual design remains consistent and doesn’t stretch awkwardly.
  • The scaling behavior (via “Scale With Screen Size”) is proportional.
  • The panel always stays centered.

Chest Border:

Create another UI Image as a child of the ChestPanel, and rename it to ChestBorder.

In the Image component:

  • Assign your desired border sprite to the Source Image field. This will visually frame your chest panel.
  • Set the Rect Transform’s Width and Height to match the ChestPanel.
  • Set Pixel Per Unit Multiplier to 2
Chest Border

Now, split the anchors and ensure that the Rect Transform values are 0.

Split Border Anchor

Why split the anchors here?

Unlike the ChestPanel, which should maintain a fixed size and centered position, the ChestBorder is a decorative frame that we want to scale exactly with the panel—no matter its size. By stretching its anchors to match all sides of the parent (ChestPanel), the ChestBorder will always adapt to the panel’s size, keeping the border proportionally aligned at all times. We will be doing this for the rest of the elements in the chest.

Chest Sprite Setup

The chest will be composed of two separate sprites: the bottom half and the top half of the chest. This setup allows us to simulate the chest opening animation by simply deactivating (or hiding) the top half when the chest is opened.

💡 If you have a chest opening animation available, you can replace this setup with the animation for a smoother visual effect.

Chest Sprites

Inside the ChestPanel, create two UI Images named ChestFront and ChestTop.
Assign the appropriate sprites (bottom and top half of the chest) in their Image components, and set their RectTransform size to 100×100.

Chest Sprite

Creating Chest Buttons

Create two buttons under the ChestPanel:

  • Open Chest Button – This appears when the chest UI pops up and triggers the chest opening animation and reward display.
  • Done Button – This appears after the chest has been opened, allowing the player to close the UI and resume the game.

Create a UI Buttons under the ChestPanel and rename them to OpenButton. Inside the button, select the child object named Text (TMP) and update the text in the TextMeshPro component to match the button’s purpose—such as “Open” for OpenButton.

Next, select the button and in the Image component, change its color to #2F59FF, or any color that suits your project’s style. Then, in the Rect Transform, set the Width to 200 and Height to 50, or adjust these values as needed to better fit your UI layout.

Open Chest

Just like the Chest Panel, we’ll add a border to our button. To do this, create a UI Image as a child of the button GameObject. Match its size to the button, assign the border sprite to the Image component and set its Pixels Per Unit Multiplier to 3

Finally, split the anchors of the buttons so they scale proportionally with the ChestPanel and maintain consistent positioning across different screen sizes.

Open Button Anchor

Coin Text

This is a simple UI element designed to display the coins awarded from the chest. Place a TextMeshProUGUI under the ChestPanel and name it CoinText. In the Rect Transform component, set the width to 200, height to 50, and position Y to -100 (or adjust these values to fit your game’s layout). Additionally, enable AutoSize in the Text component so the text scales automatically to fit the available space. Then set the coin text to inactive.

Coin Text

Now we can enhance the look of our button with some optional decorations. To add a border, follow a similar approach as we did for the ChestBorder. Create a new UI Image as a child of the button, rename it to Button Border and assign the border sprite to its Source Image. Then, set the RectTransform‘s Width and Height to match the button’s dimensions. After that, split the anchors so they align with the button’s edges, and make sure all the RectTransform values (Pos X, Y, and Offsets) are set to 0 to ensure the border overlays the button perfectly.


Afterwards, you can simply duplicate the OpenButton, rename the duplicate to DoneButton, and update the Text (TMP) inside it to say “Done”. This way, you’ll have both buttons with consistent styling and layout, saving time and keeping your UI visually cohesive. Then set the Done Button to inactive.

b. Making the beams and rewards.

Start by creating an empty GameObject under the TreasureChest Manager and rename it BeamMask. Add a Rect Mask 2D component to this GameObject—this will act as a viewport, ensuring that any effects or rewards inside it stay visually confined within a defined area.

Next, adjust its Rect Transform:

  • Set Width to 350
  • Set Height to 250
  • Set Position Y to 115

This configuration ensures the beam effects and reward visuals appear cleanly within a defined space above the chest..

Chest mask

Create another GameObject as a child of the BeamMask GameObject and rename it Beam. This object will act as a container to group all individual beam elements together. Keeping the beams under a single parent allows for better organization and easier control of animations, activation, and layout when the treasure chest opens.

Now create a UI Image under the newly created Beam GameObject and rename it to Beam #1. In the Rect Transform component of Beam #1, set the Width to 70 and Height to 800. This long vertical shape will represent the beam effect that appears when a reward is revealed from the chest.

Chest Beam

Create another empty GameObject under the Beam #1 GameObject and rename it to WeaponBeam. This object will handle the visual effect of weapons scrolling along the beam.

To begin crafting the effect, add a Particle System component to the WeaponBeam GameObject.

Now configure the Main module of the Particle System as follows:

Main

  • Duration: 8
  • Looping:true
  • Start Lifetime: 0.5
  • Start Speed: 15
  • Start Size: 1
  • Simulation Space: Local
  • Delta Time: Unscaled Time

Emission

  • Rate Over Time: 12
  • Rate Over Distance: 0

Shape

  • Shape: Edge
  • Radius: 0.0001
  • Mode: Random
  • Spread: 0

Renderer

  • Render Mode: Billboard
  • Normal Direction: 1
  • Material: Default-ParticleSystem
  • Sorting Layer: Foreground
  • Order in Layer: 10006

Texture Sheet Animation

  • Mode: Sprites
  • Add Sprites: Add 10 different weapon sprites to the list.
    (These will cycle through as the beam scrolls)

Settings
Time Mode: FPS

FPS: 0.0001
(This extremely low value causes the particle to hold a single sprite frame, effectively making each particle “stick” to one sprite)

Start Frame:

Mode: Random Between Two Constants

Min: 0

Max: 9.999 (Ensures each particle randomly picks a sprite from the set)

You should have something like this after:

Next, create an empty GameObject under the Beam #1 GameObject and rename it ItemChosen.
This object will serve as the container for the item that appears when a reward is selected.

Rect Transform Settings:

  • Position: Adjust the Y value to place it anywhere along the beam where you want the item to appear visually.

Item Chosen gameObject will consist of:

item elements needed
  • sprite for the background for the item
  • Ui image for the item icon
  • Visual effects when item is shown

In ItemChosen gameObject create a UI Image and call it the Item Backdrop. Then assign it a sprite of your choice. Then set its Rect Transform Width and Height to 50 (or set them to your preferred values).

In the ItemChosen GameObject, create a UI Image and rename it to ItemSprite. This image will be used to display the icon of the item selected from the chest.

There’s no need to assign a sprite manually in the Inspector—this will be dynamically set through the UITreasureChest.cs script when an item is chosen. This ensures that the UI updates correctly to reflect the item awarded.

Then, set the Rect Transform of ItemSprite to a Width and Height of 30, or adjust the size as needed to best fit your design.

Now let’s begin creating some visual effects (VFX) for the item. Start by creating an empty GameObject under ItemChosen and rename it ItemVFX. This GameObject will act as the parent container for all visual effects related to the item.

By organizing it this way, you can easily activate or deactivate all associated VFX by simply toggling this parent object, which keeps your UI clean and makes managing effects much more efficient.

We will start with the ring effect that is played when item is first revealed.

Main

  • Duration: 1
  • Looping:false
  • Start Delay: 0.1
  • Start Lifetime: 0.35
  • Start Speed: 0
  • Start Size: 1
  • Simulation Space: Local
  • Delta Time: Unscaled Time

Emission

  • Rate Over Time: 0
  • Rate Over Distance: 0
  • Burst
    • Time: 0
    • Count: 1
    • Cycles: 1
    • Interval: 0.01
    • Probability: 1

Renderer

  • Render Mode: Billboard
  • Normal Direction: 1
  • Material:
ring texture for item VFX
  • Sorting Layer: Foreground
  • Order in Layer: 10006

Size Over Lifetime

  • Size:
size over life time graph

Next we will need the a shining VFX for the item.

Main

  • Duration: 1
  • Looping: true
  • Start Delay: 0
  • Start Lifetime: 1
  • Start Speed: 0
  • Start Size: 0.05
  • Simulation Space: Local
  • Delta Time: Unscaled Time

Emission

  • Rate Over Time: 0
  • Rate Over Distance: 0
  • Burst
    • Time: 0
    • Count: 1
    • Cycles: 1
    • Interval: 0.01
    • Probability: 1

Size Over Life Time

  • Size:
Size Over life Time Graph

Rotation Over Lifetime

  • Angular Velocity: 90

Renderer

  • Render Mode: Billboard
  • Normal Direction: 1
  • Material:
Shine Texture for material in Renderer
  • Sorting Layer: Foreground
  • Order in Layer: 10006

After that you will want to duplicate this VFX 3-4 times and increment its delay by 0.1-0.3 seconds. After you have done that your VFX should look like this now.

After that is done Duplicate the Beam #1 till you have 5 beams. Rename them accordingly (e.g. Beam #2,Beam #3, etc…) Then after that reposition them and rotate them so that you will have something like this:

After you have finished let’s assign the new effects to the UITreasureChest.cs script.

c. Chest Opening Visual Effects

Create a new empty GameObject and name it ChestOpeningVFX. This object will serve as the parent container for all the visual effects related to the chest opening animation.

Let’s start by creating coins that will be spewed out when chest is opened. Create a empty gameObject in the Chest Opening VFX and rename it to GoldCoinsParticles. Give it a Particle System component.

Main

  • Duration: 5
  • Looping: true
  • Start Lifetime: 2
  • Start Speed: 1
  • Start Size: 1
  • Start color: FFF500
  • gravity modifier: 1
  • Simulation Space: World
  • Delta Time: Unscaled Time

Emission

  • Rate Over Time: 25
  • Rate Over Distance: 0

Shape

  • Shape: Cone
  • Angle: 45
  • Radius: 0.0001
  • Mode: Random
  • Spread: 0

Velocity Over Lifetime

  • Linear: X = 0, Y = 2.5, Z = 0
  • Space: World
  • Radial: 5
  • Speed Modifier: 2.5

Texture Sheet Animation

  • Mode: Sprites ( set the sprite to a gold coin sprite)
  • Time Mode: Lifetime
  • Frame over Time: set to curve,
linear graph
  • Cycles: 1

Renderer

  • Render Mode: Billboard
  • Normal Direction: 1
  • Material: Default-Line
  • Sorting Layer: Foreground
  • Order in Layer: 10005

After finishing the GoldCoinParticles, duplicate it and rename the new GameObject to SilverCoinParticles. Then change the Start color to 9CC6FF. (or you can change its sprite to a Silver Coin)

Next let’s make the beams that will move around when the chest is opening. Create a VictoryBeam#1 gameObject under the ChestOpeningVFX. Now give it a Particle System component.

Main

  • Duration: 5
  • Looping: true
  • Start Lifetime: 0.1
  • Start Speed: 0
  • Start Size: 1
  • Start color: FFF500
  • gravity modifier: 1
  • Simulation Space: Local
  • Delta Time: Unscaled Time

Emission

  • Rate Over Time: 1000
  • Rate Over Distance: 0

Shape

  • Shape: Sphere
  • Radius: 0.0001
  • Mode: Random
  • Spread: 0

Renderer

  • Render Mode: Billboard
  • Normal Direction: 1
  • Material: Additive-Particle
  • Sorting Layer: Foreground
  • Order in Layer: 10005

After that we will have to animate it to move around in a 8 pattern. click on the VictoryBeam #1 gameObject then in the animation tab click create. rename it to VictoryBeam.anim. Afterwards you want to animate it like this.

After wards Duplicate the VictoryBeam #1 and rename it to VictoryBeam#2. Make a new animation for it called VictoryBeamReversed.anim and mirror the animation of the VictoryBeam.anim.

4. Updating the PlayerInventory.cs script

PlayerInventory.cs

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Linq;

public class PlayerInventory : MonoBehaviour
{
    [System.Serializable]
    public class Slot
    {
        public Item item;

        public void Assign(Item assignedItem)
        {
            item = assignedItem;
            if (item is Weapon)
            {
                Weapon w = item as Weapon;
            }
            else
            {
                Passive p = item as Passive;
            }
            Debug.Log(string.Format("Assigned {0} to player.", item.name));
        }

        public void Clear()
        {
            item = null;
        }

        public bool IsEmpty() { return item == null; }
    }
    public List<Slot> weaponSlots = new List<Slot>(6);
    public List<Slot> passiveSlots = new List<Slot>(6);
    public UIInventoryIconsDisplay weaponUI, passiveUI;

    [Header("UI Elements")]
    public List<WeaponData> availableWeapons = new List<WeaponData>();    //List of upgrade options for weapons
    public List<PassiveData> availablePassives = new List<PassiveData>(); //List of upgrade options for passive items

    public UIUpgradeWindow upgradeWindow;

    PlayerStats player;

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

    // Checks if the inventory has an item of a certain type.
    public bool Has(ItemData type) { return Get(type); }

    public Item Get(ItemData type)
    {
        if (type is WeaponData) return Get(type as WeaponData);
        else if (type is PassiveData) return Get(type as PassiveData);
        return null;
    }

    // Find a passive of a certain type in the inventory.
    public Passive Get(PassiveData type)
    {
        foreach (Slot s in passiveSlots)
        {
            Passive p = s.item as Passive;
            if (p && p.data == type)
                return p;
        }
        return null;
    }

    // Find a weapon of a certain type in the inventory.
    public Weapon Get(WeaponData type)
    {
        foreach (Slot s in weaponSlots)
        {
            Weapon w = s.item as Weapon;
            if (w && w.data == type)
                return w;
        }
        return null;
    }

    // Removes a weapon of a particular type, as specified by .
    public bool Remove(WeaponData data, bool removeUpgradeAvailability = false)
    {
        // Remove this weapon from the upgrade pool.
        if (removeUpgradeAvailability) availableWeapons.Remove(data);

        for (int i = 0; i < weaponSlots.Count; i++)
        {
            Weapon w = weaponSlots[i].item as Weapon;
            if (w.data == data)
            {
                weaponSlots[i].Clear();
                w.OnUnequip();
                Destroy(w.gameObject);
                return true;
            }
        }

        return false;
    }

    // Removes a passive of a particular type, as specified by .
    public bool Remove(PassiveData data, bool removeUpgradeAvailability = false)
    {
        // Remove this passive from the upgrade pool.
        if (removeUpgradeAvailability) availablePassives.Remove(data);

        for (int i = 0; i < weaponSlots.Count; i++)
        {
            Passive p = weaponSlots[i].item as Passive;
            if (p.data == data)
            {
                weaponSlots[i].Clear();
                p.OnUnequip();
                Destroy(p.gameObject);
                return true;
            }
        }

        return false;
    }

    // If an ItemData is passed, determine what type it is and call the respective overload.
    // We also have an optional boolean to remove this item from the upgrade list.
    public bool Remove(ItemData data, bool removeUpgradeAvailability = false)
    {
        if (data is PassiveData) return Remove(data as PassiveData, removeUpgradeAvailability);
        else if (data is WeaponData) return Remove(data as WeaponData, removeUpgradeAvailability);
        return false;
    }

    // Finds an empty slot and adds a weapon of a certain type, returns
    // the slot number that the item was put in.
    public int Add(WeaponData data)
    {
        int slotNum = -1;

        // Try to find an empty slot.
        for (int i = 0; i < weaponSlots.Capacity; i++)
        {
            if (weaponSlots[i].IsEmpty())
            {
                slotNum = i;
                break;
            }
        }

        // If there is no empty slot, exit.
        if (slotNum < 0) return slotNum;

        // Otherwise create the weapon in the slot.
        // Get the type of the weapon we want to spawn.
        Type weaponType = Type.GetType(data.behaviour);

        if (weaponType != null)
        {
            // Spawn the weapon GameObject.
            GameObject go = new GameObject(data.baseStats.name + " Controller");
            Weapon spawnedWeapon = (Weapon)go.AddComponent(weaponType);
            spawnedWeapon.transform.SetParent(transform); //Set the weapon to be a child of the player
            spawnedWeapon.transform.localPosition = Vector2.zero;
            spawnedWeapon.Initialise(data);
            spawnedWeapon.OnEquip();

            // Assign the weapon to the slot.
            weaponSlots[slotNum].Assign(spawnedWeapon);
            weaponUI.Refresh();

            // Close the level up UI if it is on.
            if (GameManager.instance != null && GameManager.instance.choosingUpgrade)
                GameManager.instance.EndLevelUp();

            return slotNum;
        }
        else
        {
            Debug.LogWarning(string.Format(
                "Invalid weapon type specified for {0}.",
                data.name
            ));
        }

        return -1;
    }

    // Finds an empty slot and adds a passive of a certain type, returns
    // the slot number that the item was put in.
    public int Add(PassiveData data)
    {
        int slotNum = -1;

        // Try to find an empty slot.
        for (int i = 0; i < passiveSlots.Capacity; i++)
        {
            if (passiveSlots[i].IsEmpty())
            {
                slotNum = i;
                break;
            }
        }

        // If there is no empty slot, exit.
        if (slotNum < 0) return slotNum;

        // Otherwise create the passive in the slot.
        // Get the type of the passive we want to spawn.
        GameObject go = new GameObject(data.baseStats.name + " Passive");
        Passive p = go.AddComponent<Passive>();
        p.Initialise(data);
        p.transform.SetParent(transform); //Set the weapon to be a child of the player
        p.transform.localPosition = Vector2.zero;

        // Assign the passive to the slot.
        passiveSlots[slotNum].Assign(p);
        passiveUI.Refresh();

        if (GameManager.instance != null && GameManager.instance.choosingUpgrade)
        {
            GameManager.instance.EndLevelUp();
        }
        player.RecalculateStats();

        return slotNum;
    }

    // If we don't know what item is being added, this function will determine that.
    public int Add(ItemData data)
    {
        if (data is WeaponData) return Add(data as WeaponData);
        else if (data is PassiveData) return Add(data as PassiveData);
        return -1;
    }

    // Overload so that we can use both ItemData or Item to level up an
    // item in the inventory.
    public bool LevelUp(ItemData data)
    {
        Item item = Get(data);
        if (item) return LevelUp(item);
        return false;
    }

    // Levels up a selected weapon in the player inventory.
    public bool LevelUp(Item item)
    {
        // Tries to level up the item.
        if (!item.DoLevelUp())
        {
            Debug.LogWarning(string.Format(
                "Failed to level up {0}.",
                 item.name
            ));
            return false;
        }

        // Update the UI after the weapon has levelled up.
        weaponUI.Refresh();
        passiveUI.Refresh();

        // Close the level up screen afterwards.
        if (GameManager.instance != null && GameManager.instance.choosingUpgrade)
        {
            GameManager.instance.EndLevelUp();
        }

        // If it is a passive, recalculate player stats.
        if (item is Passive) player.RecalculateStats();
        return true;
    }

    // Get all the slots from the player of a certain type,
    // either Weapon or Passive. If you get all slots of Item,
    // it will return all Weapons and Passives.
    public Slot[] GetSlots<T>() where T : Item
    {
        // Check which set of slots to return.
        // If you get Items, it will give you both weapon and passive slots.
        switch (typeof(T).ToString())
        {
            case "Passive":
                return passiveSlots.ToArray();
            case "Weapon":
                return weaponSlots.ToArray();
            case "Item":
                List<Slot> s = new List<Slot>(passiveSlots);
                s.AddRange(weaponSlots);
                return s.ToArray();
        }

        // If you have other subclasses of Item, you will need to add extra cases above to
        // prevent this message from triggering. This message is here to help developers pinpoint
        // the part of the code they need to update.
        Debug.LogWarning("Generic type provided to GetSlots() call does not have a coded behaviour.");
        return null;
    }

    public Slot[] GetSlotsFor<T>() where T : ItemData
    {
        if (typeof(T) == typeof(PassiveData))
        {
            return passiveSlots.ToArray();
        }
        else if (typeof(T) == typeof(WeaponData))
        {
            return weaponSlots.ToArray();
        }
        else if (typeof(T) == typeof(ItemData))
        {
            List<Slot> s = new List<Slot>(passiveSlots);
            s.AddRange(weaponSlots);
            return s.ToArray();
        }
        // If you have other subclasses of Item, you will need to add extra cases above to
        // prevent this message from triggering. This message is here to help developers pinpoint
        // the part of the code they need to update.
        Debug.LogWarning("Generic type provided to GetSlotsFor() call does not have a coded behaviour.");
        return null;
    }


// Checks a list of slots to see if there are any slots left.
int GetSlotsLeft(List<Slot> slots)
    {
        int count = 0;
        foreach (Slot s in slots)
        {
            if (s.IsEmpty()) count++;
        }
        return count;
    }

    // Generic variants of GetSlotsLeft(), which is easier to use.
    public int GetSlotsLeft<T>() where T : Item { return GetSlotsLeft(new List<Slot>( GetSlots<T>() )); }
    public int GetSlotsLeftFor<T>() where T : ItemData { return GetSlotsLeft(new List<Slot>( GetSlotsFor<T>() )); }

    public T[] GetAvailable<T>() where T : ItemData
    {
        if (typeof(T) == typeof(PassiveData))
        {
            return availablePassives.ToArray() as T[];
        }
        else if (typeof(T) == typeof(WeaponData))
        {
            return availableWeapons.ToArray() as T[];
           
        }
        else if (typeof(T) == typeof(ItemData))
        {
            List<ItemData> list = new List<ItemData>(availablePassives);
            list.AddRange(availableWeapons);
            return list.ToArray() as T[];
        }

        Debug.LogWarning("Generic type provided to GetAvailable() call does not have a coded behaviour.");
        return null;
    }

    // Get all available items (weapons or passives) that we still do not have yet.
    public T[] GetUnowned<T>() where T : ItemData
    {
        // Get all available items.
        var available = GetAvailable<T>();
        
        if (available == null || available.Length == 0)
            return new T[0]; // Return empty array if null or empty

        List<T> list = new List<T>(available);

        // Check all of our slots, and remove all items in the list that are found in the slots.
        var slots = GetSlotsFor<T>();
        if (slots != null)
        {
            foreach (Slot s in slots)
            {
                if (s?.item?.data != null && list.Contains(s.item.data as T))
                    list.Remove(s.item.data as T);
            }
        }
        return list.ToArray();
    }

    public T[] GetEvolvables<T>() where T : Item
    {
        // Check all the slots, and find all the items in the slot that
        // are capable of evolving.
        List<T> result = new List<T>();
        foreach (Slot s in GetSlots<T>())
            if (s.item is T t && t.CanEvolve(0).Length > 0) result.Add(t);
        return result.ToArray();
    }

    public T[] GetUpgradables<T>() where T : Item
    {
        // Check all the slots, and find all the items in the slot that
        // are still capable of levelling up.
        List<T> result = new List<T>();
        foreach (Slot s in GetSlots<T>())
            if (s.item is T t && t.CanLevelUp()) result.Add(t);
        return result.ToArray();
    }

    // Determines what upgrade options should appear.
    void ApplyUpgradeOptions()
    {
        // <availableUpgrades> is an empty list that will be filtered from
        // <allUpgrades>, which is the list of ALL upgrades in PlayerInventory.
        // Not all upgrades can be applied, as some may have already been
        // maxed out the player, or the player may not have enough inventory slots.
        List<ItemData> availableUpgrades = new List<ItemData>();
        List<ItemData> allUpgrades = new List<ItemData>(availableWeapons);
        allUpgrades.AddRange(availablePassives);

        // We need to know how many weapon / passive slots are left.
        int weaponSlotsLeft = GetSlotsLeft(weaponSlots);
        int passiveSlotsLeft = GetSlotsLeft(passiveSlots);

        // Filters through the available weapons and passives and add those
        // that can possibly be an option.
        foreach (ItemData data in allUpgrades)
        {
            // If a weapon of this type exists, allow for the upgrade if the
            // level of the weapon is not already maxed out.
            Item obj = Get(data);
            if (obj)
            {
                if (obj.currentLevel <= data.maxLevel) availableUpgrades.Add(data);
            }
            else
            {
                // If we don't have this item in the inventory yet, check if
                // we still have enough slots to take new items.
                if (data is WeaponData && weaponSlotsLeft > 0) availableUpgrades.Add(data);
                else if (data is PassiveData && passiveSlotsLeft > 0) availableUpgrades.Add(data);
            }
        }

        // Show the UI upgrade window if we still have available upgrades left.
        int availUpgradeCount = availableUpgrades.Count;
        if (availUpgradeCount > 0)
        {
            bool getExtraItem = 1f - 1f / player.Stats.luck > UnityEngine.Random.value;
            if (getExtraItem || availUpgradeCount < 4) upgradeWindow.SetUpgrades(this, availableUpgrades, 4);
            else upgradeWindow.SetUpgrades(this, availableUpgrades, 3, "Increase your Luck stat for a chance to get 4 items!");
        }
        else if (GameManager.instance != null && GameManager.instance.choosingUpgrade)
        {
            GameManager.instance.EndLevelUp();
        }
    }

    public void RemoveAndApplyUpgrades()
    {

        ApplyUpgradeOptions();
    }

}

In the PlayerInventory.cs script, we’ve introduced new generic methods to streamline the management of the player’s weapon and passive inventories. These generics enable flexible, type-safe handling of item storage, retrieval, upgrades, and evolution. The system keeps track of which items the player owns, which are still available, and which can be upgraded or evolved based on their current level and state. This functionality serves as the foundation for our treasure chest system, allowing it to upgrade existing items or grant new weapons and passives to the player.

a. explanation of the changes to the PlayerInventory.cs script

    // Get all the slots from the player of a certain type,
    // either Weapon or Passive. If you get all slots of Item,
    // it will return all Weapons and Passives.
    public Slot[] GetSlots<T>() where T : Item
    {
        // Check which set of slots to return.
        // If you get Items, it will give you both weapon and passive slots.
        switch (typeof(T).ToString())
        {
            case "Passive":
                return passiveSlots.ToArray();
            case "Weapon":
                return weaponSlots.ToArray();
            case "Item":
                List<Slot> s = new List<Slot>(passiveSlots);
                s.AddRange(weaponSlots);
                return s.ToArray();
        }

        // If you have other subclasses of Item, you will need to add extra cases above to
        // prevent this message from triggering. This message is here to help developers pinpoint
        // the part of the code they need to update.
        Debug.LogWarning("Generic type provided to GetSlots() call does not have a coded behaviour.");
        return null;
    }

This generic method retrieves all inventory slots of a specified item type from the player’s inventory. It’s designed to work with three types: Weapon, Passive, and their shared base class Item.

How It Works:

  • The method uses the generic type T to determine what kind of slots to return:
    • If T is Weapon, it returns all weapon slots.
    • If T is Passive, it returns all passive slots.
    • If T is Item, it returns both weapon and passive slots by combining them into a single list.

This allows you to write flexible code that can work with weapons, passives, or both—without needing separate methods for each. For example, you can call GetSlots<Weapon>() to get just weapon slots, or GetSlots<Item>() to operate on all equipped items.

💡 Note: If you use a different subclass of Item that the method doesn’t explicitly recognize, it will return null and print a warning in the console. This helps catch edge cases or new item types that haven’t been accounted for yet.

public Slot[] GetSlotsFor<T>() where T : ItemData
    {
        if (typeof(T) == typeof(PassiveData))
        {
            return passiveSlots.ToArray();
        }
        else if (typeof(T) == typeof(WeaponData))
        {
            return weaponSlots.ToArray();
        }
        else if (typeof(T) == typeof(ItemData))
        {
            List<Slot> s = new List<Slot>(passiveSlots);
            s.AddRange(weaponSlots);
            return s.ToArray();
        }
        // If you have other subclasses of Item, you will need to add extra cases above to
        // prevent this message from triggering. This message is here to help developers pinpoint
        // the part of the code they need to update.
        Debug.LogWarning("Generic type provided to GetSlotsFor() call does not have a coded behaviour.");
        return null;
    }

This generic method is essentially the same as the previous one, but it’s designed to work with ItemData instead of Item. This method retrieves the appropriate list of inventory slots (weapon or passive) based on the data class of an item (ItemData, WeaponData, or PassiveData) rather than the runtime item (Item, Weapon, or Passive).

Why This Exists Separately:

  • Item represents the actual in-game object (e.g., equipped weapon or passive).
  • ItemData is the asset data (e.g., ScriptableObject that defines the item’s stats, icon, etc.).

You’d typically use GetSlotsFor<T>() when working with item data—such as when determining upgrade options or checking which weapons or passives the player doesn’t yet own.

Keeping these as separate functions helps maintain code readability and enforces type safety, since each function operates on its specific type. Combining them into one method could cause confusion or bugs, as it would need to handle two different types within the same function.

    public int GetSlotsLeft<T>() where T : Item { return GetSlotsLeft(new List<Slot>( GetSlots<T>() )); }

    public int GetSlotsLeftFor<T>() where T : ItemData { return GetSlotsLeft(new List<Slot>( GetSlotsFor<T>() )); }

These utility functions build on the two generic methods we discussed earlier. They provide an easy and convenient way to get the number of empty slots in the inventory for both Item and ItemData types, helping keep the code clean and readable.

You can now simply call GetSlotsLeft<Weapon>() (or replace Weapon with any other item type) in any function to quickly retrieve the number of empty slots left for that type. This approach keeps the code clean and readable, making it immediately clear what the function call is doing.

GetSlotsLeft(new List<Slot>( GetSlotsFor<T>() )); }

    public T[] GetAvailable<T>() where T : ItemData
    {
        if (typeof(T) == typeof(PassiveData))
        {
            return availablePassives.ToArray() as T[];
        }
        else if (typeof(T) == typeof(WeaponData))
        {
            return availableWeapons.ToArray() as T[];
           
        }
        else if (typeof(T) == typeof(ItemData))
        {
            List<ItemData> list = new List<ItemData>(availablePassives);
            list.AddRange(availableWeapons);
            return list.ToArray() as T[];
        }

        Debug.LogWarning("Generic type provided to GetAvailable() call does not have a coded behaviour.");
        return null;
    }

what about this?

The GetAvailable<T>() method provides an array of all available items of a given type (PassiveData, WeaponData, or all ItemData) that the player can acquire. If the generic type doesn’t match these known types, it logs a warning and returns null.

 // Get all available items (weapons or passives) that we still do not have yet.
 public T[] GetUnowned<T>() where T : ItemData
 {
     // Get all available items.
     var available = GetAvailable<T>();
     
     if (available == null || available.Length == 0)
         return new T[0]; // Return empty array if null or empty

     List<T> list = new List<T>(available);

     // Check all of our slots, and remove all items in the list that are found in the slots.
     var slots = GetSlotsFor<T>();
     if (slots != null)
     {
         foreach (Slot s in slots)
         {
             if (s?.item?.data != null && list.Contains(s.item.data as T))
                 list.Remove(s.item.data as T);
         }
     }
     return list.ToArray();
 }

When adding weapons and passives to the player inventory we must also make sure that the player will not get an item they already own. Inorder to ensure this we would need a function that will get the list of all the avaliable Item type. It will then get the slots of the same item type check through each slot to check for an Item and removing any from the lost of avaliable items.

Now, you can easily call GetUnowned<Weapon>()(or replace Weapon with any other item type) to retrieve a list of weapons that the player does not currently own.

 public T[] GetEvolvables<T>() where T : Item
 {
     // Check all the slots, and find all the items in the slot that
     // are capable of evolving.
     List<T> result = new List<T>();
     foreach (Slot s in GetSlots<T>())
         if (s.item is T t && t.CanEvolve(0).Length > 0) result.Add(t);
     return result.ToArray();
 }

This method scans all inventory slots of a given item type and checks each item using the CanEvolve method to determine if it has any available evolution options. It then returns an array of items that are eligible for evolution.

  public T[] GetUpgradables<T>() where T : Item
  {
      // Check all the slots, and find all the items in the slot that
      // are still capable of levelling up.
      List<T> result = new List<T>();
      foreach (Slot s in GetSlots<T>())
          if (s.item is T t && t.CanLevelUp()) result.Add(t);
      return result.ToArray();
  }

his method scans all inventory slots of a given item type and checks each item using the CanLevelUp method to determine if it is still eligible for an upgrade. It then returns an array of items that can be leveled up.

5. TreasureChest.cs script

using System.Collections;
using System.Collections.Generic;
using TMPro;
using Unity.PlasticSCM.Editor.WebApi;
using UnityEngine;

public class TreasureChest : MonoBehaviour
{
    [System.Flags]
    public enum DropType
    {
        NewPassive = 1, NewWeapon = 2, UpgradePassive = 4,
        UpgradeWeapon = 8, Evolution = 16
    }
    public DropType possibleDrops = (DropType)~0;

    public enum DropCountType { sequential, random }
    public DropCountType dropCountType = DropCountType.sequential;
    public TreasureChestDropProfile[] dropProfiles;
    public static int totalPickups = 0;
    int currentDropProfileIndex = 0;

    PlayerInventory recipient;

    // Get the number of rewards the treasure chest provides, retrieved
    // from the assigned drop profiles.
    private int GetRewardCount()
    {
        TreasureChestDropProfile dp = GetNextDropProfile();
        if(dp) return dp.noOfItems;
        return 1;
    }

    public TreasureChestDropProfile GetCurrentDropProfile()
    {
        return dropProfiles[currentDropProfileIndex];
    }

    // Get a drop profile from a list of drop profiles assigned to the treasure chest.
    public TreasureChestDropProfile GetNextDropProfile()
    {
        if (dropProfiles == null || dropProfiles.Length == 0)
        {
            Debug.LogWarning("Drop profiles not set.");
            return null;
        }

        switch (dropCountType)
        {
            case DropCountType.sequential:
                currentDropProfileIndex = Mathf.Clamp(
                    totalPickups, 0,
                    dropProfiles.Length - 1
                );
                break;

            case DropCountType.random:

                float playerLuck = recipient.GetComponentInChildren<PlayerStats>().Actual.luck;

                // Build list of profiles with computed weight
                List<(int index, TreasureChestDropProfile profile, float weight)> weightedProfiles = new List<(int, TreasureChestDropProfile, float)>();
                for (int i = 0; i < dropProfiles.Length; i++)
                {
                    float weight = dropProfiles[i].baseDropChance * (1 + dropProfiles[i].luckScaling * (playerLuck - 1));
                    weightedProfiles.Add((i, dropProfiles[i], weight));
                }

                // Sort by weight ascending (smallest first)
                weightedProfiles.Sort((a, b) => a.weight.CompareTo(b.weight));

                // Compute total weight
                float totalWeight = 0f;
                foreach (var entry in weightedProfiles)
                    totalWeight += entry.weight;

                // Random roll and cumulative selection
                float r = Random.Range(0, totalWeight);
                float cumulative = 0f;
                foreach (var entry in weightedProfiles)
                {
                    cumulative += entry.weight;
                    if (r <= cumulative)
                    {
                        currentDropProfileIndex = entry.index;
                        return entry.profile;
                    }
                }
                break;
        }

        return GetCurrentDropProfile();
    }


    private void OnTriggerEnter2D(Collider2D col)
    {
        if (col.TryGetComponent(out PlayerInventory p))
        {
            // Save the recipient and start up the UI.
            recipient = p;

            // Rewards will be given first.
            int rewardCount = GetRewardCount();
            for (int i = 0; i < rewardCount; i++)
            {
                Open(p);
            }
            gameObject.SetActive(false);

            UITreasureChest.Activate(p.GetComponentInChildren<PlayerCollector>(), this);

            // Increment first, then wrap around if necessary
            totalPickups = (totalPickups + 1) % (dropProfiles.Length + 1);
        }
    }

    // Continue down the list until one returns.
    void Open(PlayerInventory inventory)
    {
        if (inventory == null) return;

        if (possibleDrops.HasFlag(DropType.Evolution) && TryEvolve<Weapon>(inventory)) return;
        if (possibleDrops.HasFlag(DropType.UpgradeWeapon) && TryUpgrade<Weapon>(inventory)) return;
        if (possibleDrops.HasFlag(DropType.UpgradePassive) && TryUpgrade<Passive>(inventory)) return;
        if (possibleDrops.HasFlag(DropType.NewWeapon) && TryGive<WeaponData>(inventory)) return;
        if (possibleDrops.HasFlag(DropType.NewPassive)) TryGive<PassiveData>(inventory);
    }

    // Try to evolve a random item in the inventory.
    T TryEvolve<T>(PlayerInventory inventory) where T : Item
    {
        // Loop through every evolvable item.
        T[] evolvables = inventory.GetEvolvables<T>();
        foreach (Item i in evolvables)
        {
            // Get all the evolutions that are possible.
            ItemData.Evolution[] possibleEvolutions = i.CanEvolve(0);
            foreach (ItemData.Evolution e in possibleEvolutions)
            {
                // Attempt the evolution and notify the UI if successful.
                if (i.AttemptEvolution(e, 0))
                {
                    UITreasureChest.NotifyItemReceived(e.outcome.itemType.icon);
                    return i as T;
                }
            }
        }
        return null;
    }

    // Try to upgrade a random item in the inventory.
    T TryUpgrade<T>(PlayerInventory inventory) where T : Item
    {
        // Gets all weapons in the inventory that can still level up.
        T[] upgradables = inventory.GetUpgradables<T>();
        if (upgradables.Length == 0) return null; // Terminate if no weapons.

        // Do the level up, and tell the treasure chest which item is levelled.
        T t = upgradables[Random.Range(0, upgradables.Length)];
        inventory.LevelUp(t);
        UITreasureChest.NotifyItemReceived(t.data.icon);
        return t;
    }

    // Try to give a new item to the inventory.
    T TryGive<T>(PlayerInventory inventory) where T : ItemData
    {
        // Get all new item possibilities.
        T[] possibilities = inventory.GetUnowned<T>();
        if (possibilities.Length == 0) return null;
        

        // Add a random possibility.
        T t = possibilities[Random.Range(0, possibilities.Length)];
        inventory.Add(t);
        UITreasureChest.NotifyItemReceived(t.icon);
        return t;
    }
}

This script is attached to the TreasureChest prefab and handles how the chest delivers rewards to the player. It defines the logic for evolving, upgrading and giving items, such as weapons or passive items.

a. explanation of the TreasureChest.cs script

[System.Flags]
    public enum DropType
    {
        NewPassive = 1, NewWeapon = 2, UpgradePassive = 4,
        UpgradeWeapon = 8, Evolution = 16
    }
    public DropType possibleDrops = (DropType)~0;

    public enum DropCountType { sequential, random }
    public DropCountType dropCountType = DropCountType.sequential;
    public TreasureChestDropProfile[] dropProfiles;
    public static int totalPickups = 0;
    int currentDropProfileIndex = 0;

    PlayerInventory recipient;

These are the variables used by the TreasureChest.cs script to manage its behavior and control how rewards are given to the player.

[System.Flags]
    public enum DropType
    {
        NewPassive = 1, NewWeapon = 2, UpgradePassive = 4,
        UpgradeWeapon = 8, Evolution = 16
    }

    public DropType possibleDrops = (DropType)~0;
possible drops variable

This is how the line of code appears in the Inspector—it creates a dropdown with options you can toggle. The purpose of this variable is to let you customize the types of rewards the chest can give to the player.

    public enum DropCountType { sequential, random }
    public DropCountType dropCountType = DropCountType.sequential;
    public TreasureChestDropProfile[] dropProfiles;

We define a simple enum called DropCountType with two values: Sequential and Random. This enum is stored in a variable named dropCountType and is used to control how the chest determines the number of rewards to drop.

We also add a variable named dropProfiles, which is an array of TreasureChestDropProfile ScriptableObjects. By using an array, we can store multiple drop profiles with different reward configurations.

This is where dropCountType comes into play:

  • If Random is selected, the chest will ignore the order and randomly select one of the profiles from the array each time it’s opened.
  • If Sequential is selected, the chest will go through the dropProfiles array in order—cycling through each profile one by one. For example, if the array contains profiles like 1-1-1-3-1-5, then every 4th chest would be guaranteed to drop 3 items.
  public static int totalPickups = 0;

This static variable keeps track of the total number of treasure chests that have been opened across the entire game session. Because it’s static, its value is shared among all instances of the TreasureChest class. It’s mainly used when the DropCountType is set to Sequential, helping the script determine which TreasureChestDropProfile to use next based on how many chests have already been opened

int currentDropProfileIndex = 0;

This variable holds the index of the currently selected drop profile from the dropProfiles array. It gets updated either sequentially (based on totalPickups) or randomly, depending on the value of the dropCountType enum. This helps determine how many items this specific chest should drop

PlayerInventory recipient;

This variable stores a reference to the PlayerInventory of the player who triggered the chest. It allows the script to know who should receive the rewards and ensures that the correct inventory is modified when rewards like new items, upgrades, or evolutions are granted.

   public TreasureChestDropProfile GetNextDropProfile()
{
    if (dropProfiles == null || dropProfiles.Length == 0)
    {
        Debug.LogWarning("Drop profiles not set.");
        return null;
    }

    switch (dropCountType)
    {
        case DropCountType.sequential:
            currentDropProfileIndex = Mathf.Clamp(
                totalPickups, 0,
                dropProfiles.Length - 1
            );
            break;

        case DropCountType.random:

            float playerLuck = recipient.GetComponentInChildren<PlayerStats>().Actual.luck;

            // Build list of profiles with computed weight
            List<(int index, TreasureChestDropProfile profile, float weight)> weightedProfiles = new List<(int, TreasureChestDropProfile, float)>();
            for (int i = 0; i < dropProfiles.Length; i++)
            {
                float weight = dropProfiles[i].baseDropChance * (1 + dropProfiles[i].luckScaling * (playerLuck - 1));
                weightedProfiles.Add((i, dropProfiles[i], weight));
            }

            // Sort by weight ascending (smallest first)
            weightedProfiles.Sort((a, b) => a.weight.CompareTo(b.weight));

            // Compute total weight
            float totalWeight = 0f;
            foreach (var entry in weightedProfiles)
                totalWeight += entry.weight;

            // Random roll and cumulative selection
            float r = Random.Range(0, totalWeight);
            float cumulative = 0f;
            foreach (var entry in weightedProfiles)
            {
                cumulative += entry.weight;
                if (r <= cumulative)
                {
                    currentDropProfileIndex = entry.index;
                    return entry.profile;
                }
            }
            break;
    }

    return GetCurrentDropProfile();
}

In the GetNextDropProfile() method:

  • if the drop count type is set to sequential, it first clamps the totalPickups value to make sure currentDropProfileIndex stays within the valid range of the dropProfiles array indices, then returns the drop profile at that index.
  • If the drop count type is random, it picks a random index within the array bounds and returns the drop profile at that random index. This way, the method ensures the correct drop profile is chosen based on the selected drop behavior.
  public TreasureChestDropProfile GetCurrentDropProfile()
  {
      return dropProfiles[currentDropProfileIndex];
  }

The GetCurrentDropProfile() method returns the drop profile currently selected by the currentDropProfileIndex. It simply accesses the dropProfiles array at that index and provides the corresponding TreasureChestDropProfile object. This lets other parts of the code easily retrieve the drop profile that the chest will use to determine rewards.

  private int GetRewardCount()
  {
      TreasureChestDropProfile dp = GetNextDropProfile();
      if(dp) return dp.noOfItems;
      return 1;
  }

This method retrieves the next drop profile using GetNextDropProfile(). It then checks if the drop profile is valid (not null). If valid, it returns the number of items specified by that drop profile (noOfItems). If the drop profile is null, it defaults to returning 1, meaning the chest will give one reward item.

    private void OnTriggerEnter2D(Collider2D col)
    {
        if (col.TryGetComponent(out PlayerInventory p))
        {
            // Save the recipient and start up the UI.
            recipient = p;

            // Rewards will be given first.
            int rewardCount = GetRewardCount();
            for (int i = 0; i < rewardCount; i++)
            {
                Open(p);
            }
            gameObject.SetActive(false);

            UITreasureChest.Activate(p.GetComponentInChildren<PlayerCollector>(), this);

            // Increment first, then wrap around if necessary
            totalPickups = (totalPickups + 1) % (dropProfiles.Length + 1);
        }
    }

The chest will open when the player walks over or touches it, detected using OnTriggerEnter2D(). Inside this method, we check if the collider belongs to an object with a PlayerInventory component—this ensures only the player can open the chest. We then tell the UITreasureChest.cs script that the chest has been opened.

Then, we use a for loop to open the chest multiple times based on the number of items specified by the current drop profile, giving multiple rewards if needed.

After giving out the rewards, the chest is deactivated by setting its GameObject to inactive. Finally, we increment the totalPickups counter by one, using a modulo operation to wrap it around and prevent it from going out of bounds.

    // Continue down the list until one returns.
    void Open(PlayerInventory inventory)
    {
        if (inventory == null) return;

        if (possibleDrops.HasFlag(DropType.Evolution) && TryEvolve<Weapon>(inventory)) return;
        if (possibleDrops.HasFlag(DropType.UpgradeWeapon) && TryUpgrade<Weapon>(inventory)) return;
        if (possibleDrops.HasFlag(DropType.UpgradePassive) && TryUpgrade<Passive>(inventory)) return;
        if (possibleDrops.HasFlag(DropType.NewWeapon) && TryGive<WeaponData>(inventory)) return;
        if (possibleDrops.HasFlag(DropType.NewPassive)) TryGive<PassiveData>(inventory);
    }

This method controls how the chest distributes rewards. It sequentially checks each possible drop type—starting with Evolution—based on the possibleDrops flags set on the chest.

For each type, it calls a corresponding function (TryEvolve, TryUpgrade, or TryGive) and immediately stops once a valid reward is given (i.e., the function returns successfully). The order of priority is:

  1. Try evolving a weapon
  2. Try upgrading a weapon
  3. Try upgrading a passive
  4. Try giving a new weapon
  5. Try giving a new passive

This ensures that the most impactful rewards (like evolutions) are attempted first, falling back to simpler rewards only if higher-priority options aren’t available. As a result, the chest will always prioritize giving an evolution whenever it’s possible—mirroring the behavior seen in Vampire Survivors, where chests guarantee an evolution when the conditions are met.

💡Note: This is just one example of how the reward logic can be structured. You can freely modify the order or priority of drop types to suit your game’s design. Whether you want to always prioritize evolutions, randomize everything, or favor new items over upgrades, the system is flexible and easy to customize.

    // Try to evolve a random item in the inventory.
    T TryEvolve<T>(PlayerInventory inventory) where T : Item
    {
        // Loop through every evolvable item.
        T[] evolvables = inventory.GetEvolvables<T>();
        foreach (Item i in evolvables)
        {
            // Get all the evolutions that are possible.
            ItemData.Evolution[] possibleEvolutions = i.CanEvolve(0);
            foreach (ItemData.Evolution e in possibleEvolutions)
            {
                // Attempt the evolution and notify the UI if successful.
                if (i.AttemptEvolution(e, 0))
                {
                    UITreasureChest.NotifyItemReceived(e.outcome.itemType.icon)
                    return i as T;
                }
            }
        }
        return null;
    }

Purpose:

The TryEvolve<T>() method attempts to evolve an item of type T (where T is a type of Item, like a weapon). It goes through all the evolvable items in the player’s inventory and tries to evolve one of them.


Breakdown:

T[] evolvables = inventory.GetEvolvables<T>();
  • Gets all items of type T that can evolve (e.g., weapons that meet evolution conditions).
  • The GetEvolvables<T>() function returns an array of items from the player’s inventory that have valid evolution paths.

foreach (Item i in evolvables)
  • Loops through each evolvable item.

ItemData.Evolution[] possibleEvolutions = i.CanEvolve(0);
  • For each item, gets a list of possible evolutions it can currently perform (depending on conditions like required item pairs or level).

foreach (ItemData.Evolution e in possibleEvolutions)
  • Loops through all the valid evolutions for that item.

if (i.AttemptEvolution(e, 0))
  • Tries to perform the evolution.
  • If successful:
    • Returns the evolved item (cast back to type T).

return null;
  • If no evolution was successful, the method returns null.
    // Try to upgrade a random item in the inventory.
    T TryUpgrade<T>(PlayerInventory inventory) where T : Item
    {
        // Gets all weapons in the inventory that can still level up.
        T[] upgradables = inventory.GetUpgradables<T>();
        if (upgradables.Length == 0) return null; // Terminate if no weapons.

        // Do the level up, and tell the treasure chest which item is levelled.
        T t = upgradables[Random.Range(0, upgradables.Length)];
        inventory.LevelUp(t);
        UITreasureChest.NotifyItemReceived(t.data.icon)
        return t;
    }

This method tries to upgrade an item (like a weapon or passive) that the player already owns.

How it works:

T[] upgradables = inventory.GetUpgradables<T>();
  • Retrieves all items of type T from the inventory that are eligible to level up.
if (upgradables.Length == 0) return null;
  • If there are no upgradable items, the function exits early and returns null.
T t = upgradables[Random.Range(0, upgradables.Length)];
inventory.LevelUp(t);
return t;
  • Picks one eligible item at random, levels it up, and returns the upgraded item.

  // Try to give a new item to the inventory.
  T TryGive<T>(PlayerInventory inventory) where T : ItemData
  {
      // Get all new item possibilities.
      T[] possibilities = inventory.GetUnowned<T>();
      if (possibilities.Length == 0) return null;
      

      // Add a random possibility.
      T t = possibilities[Random.Range(0, possibilities.Length)];
      inventory.Add(t);
      UITreasureChest.NotifyItemReceived(t.data.icon);
      return t;
  }

This method tries to give the player a new item that they don’t already own.

How it works:

T[] possibilities = inventory.GetUnowned<T>();
  • Gets all items of type T that the player does not yet own.
if (possibilities.Length == 0) return null;
  • If the player already owns everything of that type, return null.
T t = possibilities[Random.Range(0, possibilities.Length)];
inventory.Add(t);
return t;
  • Picks a random item from the list of unowned items and adds it to the inventory.
  • Returns the item that was given.

6. Making the Treasure Chest Prefab

Now it’s time to create the Treasure Chest prefab. In your Unity scene, navigate to the Hierarchy panel, right-click, and select Create Empty. Name this new GameObject TreasureChest.

Next, with the TreasureChest GameObject selected, go to the Inspector and add a Sprite Renderer, Box Collider 2D component and the TreasureChest.cs Script. In the Sprite Renderer, assign a sprite of your choice to represent the treasure chest, set sorting layer to foreground. Then, adjust the size of the Box Collider 2D to match the dimensions of your sprite, and make sure to check the Is Trigger box.

Treasure chest Prefab properties config

In the TreasureChest.cs script, you’ll want to configure the variables to match your project’s specific needs. You can also copy these variable directly from here

TreasureChest Script variabes


Finally, navigate to Project > Prefabs > TreasureChest, then drag and drop the TreasureChest GameObject from the Hierarchy into that folder to save it as a prefab.

Making Treasure chest Prefab

7. Conclusion

After completing all the steps and setting the required variables, open your level scene and drag the treasure chest prefab into the scene. Click Play to test your new treasure chest in action.

Silver Patrons and above can also download the project files.

💡 Note: If the VFX is not showing up for you try checking its Rect Transform – Pos Z, then change it to 0, -10 or -20.