Sharing is caring… Especially with code

Debugging Unity: Tracking Down an Unexpected Inventory Anomaly

Written by

Sometimes, debugging isn’t about immediately finding the line of code responsible for a problem. It’s about gathering enough information to narrow down where things are going wrong.

While developing my next Unity inventory tutorial project, I encountered an unusual issue affecting inventory interactions. Distributing items across multiple slots would occasionally stop working correctly. The behavior was inconsistent, and I couldn’t immediately identify the cause.

Rather than randomly changing code and hoping for the best, I started investigating.

In this article, I’ll walk through my debugging process, explain how strategically placed Debug.Log() statements helped narrow down the problem, and show the defensive code I added to protect the inventory against an unexpected state.

I’ll also cover the tests I performed across different Unity versions and operating systems.

One important note: the anomaly remains under investigation. I found a practical way to avoid the observed behavior during my subsequent testing, but I haven’t established its underlying cause.

1. Understanding the Problem

The inventory system includes a setting that determines how items are picked up.

Depending on the selected behavior, clicking an item can either place it into an available inventory slot or pick it up and attach it to the cursor.

The relevant setting in the InventoryManager looks like this:

[Header("Interaction Settings")]
[SerializeField] private bool defaultPickUpToCursor = false;

public bool ShouldPickUpToCursor => defaultPickUpToCursor;

This allows me to change the default pickup behavior through the Unity Inspector.

During testing, I noticed that distributing individual items across multiple slots would occasionally stop working as expected.

Initially, I suspected the new pickup setting might be involved. However, I couldn’t consistently reproduce the issue, and changing the setting didn’t give me a reliable way to trigger it.

That made the problem more difficult to investigate.

When a problem is intermittent, the first step is to stop guessing and start collecting information.

2. Using Debug.Log() to Narrow Down the Problem

Unity provides a straightforward way to inspect program execution: Debug.Log().

Although breakpoints are useful when stepping through code in a debugger, strategically placed log statements can help identify which parts of a process are executing and which conditions are being met.

The important word here is strategically.

Adding dozens of log statements throughout a project can produce an overwhelming amount of output. Instead, start with the major stages of the operation and gradually narrow your investigation.

For my inventory system, I focused on the method responsible for distributing individual items into inventory slots:

PaintOneItemIntoSlot()

This method is used when the player distributes items individually across slots, such as when using the right mouse button to paint items into multiple inventory slots.

The method needs to determine whether the destination slot is empty, whether it contains a compatible stack, and whether the held item can be placed there.

A simplified version of that decision-making process looks like this:

InventoryItem slotItem = manager.GetItemAtSlot(slotIndex);
InventoryItem cursorItem = ItemCursor.Instance.GetHeldItem();

if (cursorItem == null || cursorItem.currentQuantity <= 0)
{
    return;
}

if (slotItem == null)
{
    // Create a new inventory stack.
}
else if (slotItem.itemData == cursorItem.itemData)
{
    // Add an item to the existing stack if capacity allows.
}
else
{
    // The destination slot cannot accept this item.
}

This is a simplified example of the logic, not a replacement for the complete method.

Notice how much depends on the values returned by GetItemAtSlot() and GetHeldItem().

If either reference contains something unexpected, the method might take a different execution path than intended.

I added diagnostic messages at key decision points to determine which branches were executing.

For example:

Debug.Log($"Attempting to paint item into slot {slotIndex}");

if (cursorItem == null)
{
    Debug.LogWarning("No item is currently held by the cursor.");
    return;
}

if (cursorItem.currentQuantity <= 0)
{
    Debug.LogWarning("The cursor item has no quantity remaining.");
    return;
}

if (slotItem == null)
{
    Debug.Log($"Slot {slotIndex} is empty.");
}
else if (slotItem.itemData == cursorItem.itemData)
{
    Debug.Log($"Slot {slotIndex} contains a matching item.");
}
else
{
    Debug.Log($"Slot {slotIndex} cannot accept this item.");
}

These messages make it easier to follow the method’s execution without having to step through every line manually.

They also help answer an important question: is the method failing to execute, returning early, or executing a branch that doesn’t behave as expected?

In my case, the investigation narrowed the problem down to PaintOneItemIntoSlot() and the state of the inventory items it was retrieving.

That gave me a much smaller area of the codebase to investigate.

A Note About Diagnostic Logging

Logging should help you answer a specific question.

If you’re trying to determine whether a method executes, log its entry point. If you’re trying to understand why it returns early, log the relevant condition and the values being checked.

For example:

Debug.Log(
    $"Slot {slotIndex}: " +
    $"slotItemIsNull={slotItem == null}, " +
    $"cursorItemIsNull={cursorItem == null}"
);

This is more useful than a generic message such as "Something went wrong" because it records information about the actual state of the application.

When investigating reference-related problems, checking whether an object exists is only part of the story. An object can exist while one or more of its important fields contain unexpected values.

That distinction became relevant during my investigation.

3. Testing Across Different Unity Versions and Platforms

When I couldn’t establish a consistent explanation for the behavior, I wanted to determine whether it was isolated to my development environment.

I first reproduced the issue on a Windows laptop running Unity 2022.3.6f1.

I then tested it on my Mac using Unity 2023.2.22f1.

For these tests, I copied the project’s Assets folder rather than carrying over the entire Unity project, including its Library folder.

The purpose was to see whether the behavior followed the project across different environments rather than relying on one local installation.

The results were interesting.

I observed the behavior across different machines and Unity versions. This made an issue isolated to a single machine or Unity installation less likely, although it didn’t establish the cause.

I also considered whether changing the Inspector setting or returning focus to the Scene or Game view might be involved.

At this stage, however, I didn’t have enough evidence to attribute the behavior to a specific event or Unity subsystem.

This is an important part of debugging: reproducing a problem in multiple environments can help eliminate possibilities, but it doesn’t automatically identify the cause.

It’s tempting to settle on the first explanation that seems plausible. Resist that temptation until the evidence supports it.

4. Investigating the Inventory Array

The next step was to examine the inventory data itself.

The InventoryManager creates its array dynamically in Awake():

[Header("Inventory Settings")]
public int totalSlots = 9;

[SerializeField] private InventoryItem[] inventorySlots;

private void Awake()
{
    if (Instance != null && Instance != this)
    {
        Destroy(gameObject);
        return;
    }

    Instance = this;

    inventorySlots = new InventoryItem[totalSlots];

    InitializeInventoryUI();
}

The relevant line is:

inventorySlots = new InventoryItem[totalSlots];

This creates a new array with the requested number of elements.

Because InventoryItem is a reference type, every element in this newly created array initially contains a null reference.

That is the expected starting state.

The InventoryItem class itself is marked [System.Serializable]:

[System.Serializable]
public class InventoryItem
{
    public CollectibleItem itemData;
    public int currentQuantity;
    public GameObject originalGameObject;

    public InventoryItem(
        CollectibleItem data,
        int quantity,
        GameObject originalGO = null)
    {
        itemData = data;
        currentQuantity = quantity;
        originalGameObject = originalGO;
    }
}

This allows Unity to serialize instances of the class when they are part of serialized data.

The array also had a [SerializeField] attribute, allowing Unity to serialize the private field and making it available for inspection through the Inspector.

I initially retained that attribute because being able to inspect the inventory array during runtime was useful for debugging.

However, during the investigation, I observed the array’s elements changing from null references to InventoryItem instances whose fields were empty:

  • itemData was null.
  • currentQuantity was 0.
  • originalGameObject was null.

These weren’t the populated inventory stacks I expected to see.

I investigated the code paths that could change the array and used the debugger to examine its state at different points during execution.

I also placed breakpoints at the locations where I expected the array to be changed. Those breakpoints didn’t reveal an assignment that explained the behavior.

Despite this, the unexpected state remained an observation I couldn’t fully explain.

One possibility I considered was that some form of lingering serialized state or stale Inspector/runtime state might be involved. However, I couldn’t establish that this was the cause.

At this point, I had a symptom worth documenting, but not a confirmed root cause.

5. Adding a Defensive Check

Even without a definitive explanation, I could still make the inventory more defensive.

The existing method already distinguished between an empty slot and a slot containing an item. However, it assumed that an existing InventoryItem would have a valid itemData reference.

The unexpected state I’d observed suggested that this assumption deserved an additional check.

Before proceeding with the normal empty-slot logic, I added the following:

// Self-healing check: Catch lingering serialized state data or stale
// Inspector/runtime cache where the slot container exists but its itemData
// reference is null. Automatically clear it out to prevent silent blocking.
if (slotItem != null && slotItem.itemData == null)
{
    Debug.LogWarning(
        $"Found lingering state/invalid slot at index {slotIndex} " +
        "with null itemData. Clearing it."
    );

    manager.SetItemAtSlot(slotIndex, null);
    slotItem = null;
}

This check handles two different conditions.

First, it confirms that slotItem isn’t null. We don’t want to access a field on a nonexistent object.

Second, it checks whether the existing item’s itemData reference is null.

If both conditions are true, the code logs a warning, clears the slot through the inventory manager, and updates the local variable to reflect that the slot is now empty.

The method can then continue through its normal empty-slot logic.

I refer to this as a self-healing check because it detects an unexpected state and attempts to recover automatically instead of allowing that state to silently interfere with item placement.

The comment mentions lingering serialized state and a possible stale Inspector/runtime cache because those were among the explanations I considered. However, those explanations remain unconfirmed.

This is a defensive safeguard, not a fix for the unexplained behavior itself.

It protects the item-placement code from encountering an existing InventoryItem that lacks its required item data. It doesn’t explain why such an object appeared in the first place.

That distinction matters.

A defensive check can make an application more resilient without resolving the underlying cause of an unexpected state.

6. Testing With and Without SerializeField

The next step was to investigate whether the [SerializeField] attribute on inventorySlots was relevant.

Since the array is created dynamically and the inventory functions without the attribute, I decided to test the behavior with and without it.

The declaration originally looked like this:

[SerializeField] private InventoryItem[] inventorySlots;

I removed the attribute:

private InventoryItem[] inventorySlots;

The inventory continued to function, but I could no longer use the Inspector to inspect that private array in the same way.

More importantly, during subsequent tests, the unexpected behavior did not appear.

I repeated the tests across multiple sessions, both with and without the attribute. With [SerializeField] removed, I did not see the diagnostic warning from my defensive check appear in the Console.

That was significant because the warning would have indicated that the code encountered an existing InventoryItem whose itemData reference was null.

It never appeared during those tests.

However, this result needs to be interpreted carefully.

The absence of the warning tells me that the defensive condition wasn’t encountered during those particular tests. It doesn’t establish why the behavior occurred or prove that removing [SerializeField] resolved the underlying cause.

For the tutorial, I’m keeping [SerializeField] because it allows us to inspect the inventory array in the Inspector during runtime. That visibility is useful when demonstrating how the inventory system works.

The defensive check will remain in place as an additional safeguard. If someone encounters the same anomaly, they can remove [SerializeField] as a troubleshooting step or retain it and let the self-healing check handle an invalid slot if one is encountered.

The warning is useful during debugging because it tells us when the condition occurs. Once debugging is complete, anyone who doesn’t want that message appearing in the Console can comment out the Debug.LogWarning() line while keeping the validation and cleanup logic intact.

The relationship between serialization and the anomaly remains under investigation. For now, this approach preserves the Inspector visibility needed for the tutorial while providing a defensive response to the unexpected state.

7. What I Learned From the Investigation

Although I haven’t established the root cause, the investigation reinforced several useful debugging practices.

Start by narrowing down the execution path.

Rather than adding logs everywhere, identify the method responsible for the failing operation and instrument its important decision points.

Log the state, not just the event.

Knowing that a method executed is useful. Knowing the values that determined its execution path is often much more useful.

Test across environments when the behavior is difficult to explain.

Testing different Unity versions and operating systems helped establish that the observation wasn’t limited to one particular development machine.

Separate observations from assumptions.

I observed unexpected InventoryItem instances, investigated the relevant code, and found that removing [SerializeField] changed the behavior during my subsequent tests.

I did not establish why the instances appeared or what caused the behavior to change.

Those are different statements, and keeping them separate prevents a debugging theory from being presented as a proven explanation.

Use defensive programming where it makes sense.

The additional validation protects the item-placement method from an unexpected object state. Even if the original anomaly never appears again, the check provides a safeguard against that particular invalid state.

Finally, don’t confuse a workaround with a root-cause fix.

Removing the attribute gives me a practical way forward, but it doesn’t explain the original observation. If the behavior returns, the additional logging and defensive check should provide useful information for further investigation.

Conclusion

Debugging doesn’t always end with a definitive answer.

In this case, I traced the inventory interaction problem to the item-distribution method, investigated the state of the inventory array, tested the project across different Unity versions and operating systems, and added a defensive check for an unexpected item state.

I also found that removing [SerializeField] from the dynamically created inventory array eliminated the observed behavior during my subsequent tests.

I haven’t determined why.

For the tutorial, I’m keeping [SerializeField] so viewers can inspect the inventory array in the Inspector during runtime. The defensive check also remains in place as a safeguard against invalid slots. If the warning becomes unnecessary or annoying, the Debug.LogWarning() line can be commented out while leaving the validation and cleanup logic intact.

The anomaly is still under investigation, but the inventory continues to function, and the debugging process has given me a practical way to handle the unexpected state if it occurs again.

Sometimes the most responsible conclusion is to document what you know, explain what you’ve tried, and leave the unanswered question open rather than claim you’ve solved something you haven’t.

Leave a Reply

Your email address will not be published. Required fields are marked *