And then I deleted all the art

A damage system that did nothing at all for four months, and never once said so.

A friend was building a zombie shooter in my Unity framework. He'd never written code before he started, and he taught himself over about a year, with me helping when I had time to spare — which isn't the same as having a tutor. He got it considerably further than that background suggests. Then the time and the reasons to keep going on an unpaid side project ran out, and he handed it over.

The bug I inherited: shooting a zombie did nothing.

No error. No warning. No log. The weapon fired, the raycast connected, the damage method was called, and it returned without complaint. The only evidence anything was wrong was a zombie that wouldn't die.

It had been broken for four months by the time I found it, across a handful of sessions spread over that time — and in none of them did it ever produce a single thing to go on.

This isn't a post about a beginner missing something obvious. It's about a category of bug that removes your ability to tell whether you're getting closer — and that category doesn't care how senior you are.

The setup

Damage went through an interface:

public interface IDamagable {
    public void TakeDamage(int damage) {


    }
}

That's a default interface implementation with an empty body. Unusual, but harmless at the time: CharacterBehaviour implemented IDamagable with a matching int signature and did the actual work.

public void TakeDamage(int damage) {
    if (!immortal) {
        health -= damage;
        if (health <= 0) {
            characterStateMachine.Die();
        }
    }
}

Both signatures agreed, so the class method was the implementation. The empty body in the interface never ran. Everything worked.

12 November 2024

A commit titled "SetupWeaponsData: fixing weapons" changed the parameter type on the interface method:

-    public void TakeDamage(int damage) {+    public void TakeDamage(float damage) {

And, in the same commit, the call site:

-                    damagable.TakeDamage(1);+                    damagable.TakeDamage(weaponData.damage);

weaponData.damage was a float, so that was a consistent and sensible change.

CharacterBehaviour.TakeDamage wasn't updated. It stayed int.

That's the moment the class method stopped being an implementation of the interface and quietly became an unrelated overload. Every call made through an IDamagable reference now resolved to the interface's own float member — the one with the empty body.

The damage system died, and nothing reported it. It compiled cleanly, because default interface implementations are a real language feature and the compiler had a perfectly valid method to bind to. It ran cleanly, because an empty method body isn't an error.

The project was on Unity 2022.3.37f1 when this landed, and on Unity 6000.0.28f1 by the time it was fixed — both C# 9, so the default interface implementation was fully supported throughout. The bug came through a major engine upgrade without surfacing. That's usually the moment latent problems announce themselves. This one didn't.

Neither change was a mistake on its own. The commit was a fix and it was correct in isolation; the empty interface body had been sitting there harmlessly for months. Together they produced total silence, and nothing in C# or Unity connects the two for you.

That's the shape of a drift bug: two declarations that have to agree, nothing enforcing the agreement, and a fallback that swallows the consequence instead of surfacing it.

What I fixed first, and it wasn't this

Before I went near the damage system, I cleared the loud problems.

Enemy setup was passing an empty weapon array:

zombie.Setup(appManager, zombieData, new WeaponData[0]);

But Setup reached for element zero unconditionally, along with a weapon component that zombies didn't have:

currentWeaponData = weaponData[0];
weaponBehaviour.Setup(appManager, currentWeaponData);

Index out of range, plus a null weaponBehaviour. I fixed it on 19 November with an optional parameter and a guard, and removed the weapon component from the zombie prefab.

That's the easy category of bug. It announces itself, hands you a stack trace and a line number, and the fix is obvious as soon as you've read it. It also had nothing to do with why shooting didn't work — which is worth saying, because clearing the noisy problems felt like progress while moving me no closer.

27 February: replacing the models with primitives

There's a branch in that repo called ReplaceModelsWithPrimitives.

He'd gone all in on the models — detailed characters, proper assets — and they'd been giving us trouble for a while before I took the project over. That was part of why they went. The rest of it is just how I'd have started.

My default on a prototype this small is to get everything working fully with primitives first. Not as a style choice — because until the systems are right, every model is a second thing that can be wrong. When a fully modelled character misbehaves, the candidates are a mesh, an animation state, a rig, a pivot, an offset, a collider and a transform. When a grey cube misbehaves, it's a transform and a collider. The search space collapses.

So stripping the lot back to grey cubes wasn't a manoeuvre invented for this bug. It was putting the project into the state I'd have built it in, at which point the bug had far fewer places to hide.

Deliberately reducing a project's fidelity feels like going backwards. It's usually the fastest move available, and it costs nothing you can't get back — every asset I removed was in version control. The months weren't.

The project with full character models in place
Before — full models
The same scene rebuilt with grey primitive cubes
After — primitives, commit eec792c

16 March, 15:49: putting a little back

Cubes have no visible facing. You can't tell which way a character is pointing, or where its gun is. So I added front parts — one gun cube on the player, two arm cubes on each zombie — and nothing else.

m_LocalPosition: {x: 0, y: 0, z: 0.267}
m_LocalScale: {x: 0.2, y: 0.2, z: 0.5}

Stripping to cubes isn't about minimalism, it's about control. You take everything out, then add back only what the diagnosis needs you to be able to see. I needed to see aim direction. That's two hundred bytes of prefab YAML, not a character model.

16 March, 16:31: checking the obvious suspect

Forty-two minutes later I committed "ShootingWorking: Prefab tagging and less logs", which did two things.

The first was a real fix. Child collider objects were still Untagged, and the melee path was gated on their tags:

if (gameObject.CompareTag("Player") && hitcollider.gameObject.CompareTag("Zombie")) {

The colliders sat on child cubes; the tags were on the prefab roots. Melee could never fire. That's a familiar Unity shape — the contract lives in scene data, the compiler can't see it, and nothing enforces it.

The second thing I did was wrong:

-IDamagable damagable = hitObject.GetComponent<IDamagable>();+IDamagable damagable = hitObject.GetComponent<CharacterBehaviour>();

The interface lookup looked odd to me, so I checked whether it was stopping the call from reaching the function. GetComponent<IDamagable>() was the suspicious line: asking Unity for an interface rather than a concrete type.

It isn't wrong. GetComponent handles interfaces perfectly well. And the result was still being stored in an IDamagable variable, so the call still dispatched to the interface's float member and still landed in the empty body. I changed the line, ran it, and nothing improved.

I'm leaving this in because the checks that don't pay off are the part that normally gets edited out of write-ups. It was worth thirty seconds — odd-looking line, right area of the code, plausible mechanism — and it eliminated nothing, because there was nothing there to eliminate. Fifteen minutes later I found the real cause.

16 March, 16:46: found it

"ShootingWorking: Fixed IDamagable interface function for TakeDamage, needed no body {}"

 public interface IDamagable {-    public void TakeDamage(float damage) {---    }+    public void TakeDamage(float damage);
-    private int health;+    private float health;
-    public void TakeDamage(int damage) {+    public void TakeDamage(float damage) {

Declaring the interface method without a body removes the fallback. Now there's nothing a call can bind to except a real implementation — and if the signatures don't match, it doesn't compile.

That was the actual fix, and it wasn't "I changed int to float". It's deleting the thing that let the mismatch fail quietly instead of loudly.

Why it took four months

To be accurate about that number: nobody was hunting this continuously. He stopped working on the project not long after the commit that broke it, and I came back to it in bursts over the months that followed. The calendar says four months; the real attention was a handful of sessions. This isn't a story about a long siege — it's about why those sessions didn't converge.

Two silent failures were sitting on top of each other.

Melee had two independent reasons to do nothing: the untagged colliders and the signature mismatch. Ranged had one: the mismatch.

So fixing either problem on its own changed nothing observable. Tag the colliders, and melee still does nothing. Correct the signature without tagging, and melee still does nothing. A correct partial fix produced exactly the same result as no fix at all.

That's what makes silent failures expensive. It isn't that they're hard to reason about. It's that they take away your feedback, so being half right is indistinguishable from being wrong — and after enough of that you stop believing the problem is findable and start believing you're not good enough to find it.

13 April: removing the category

The last change is the one I'd defend hardest, because it didn't fix anything, it just made it less likely to break again.

-GameObject hitObject = weaponHit.collider.gameObject;-IDamagable damagable = hitObject.GetComponent<CharacterBehaviour>();-if (damagable != null) {+Hurtbox hurtbox = weaponHit.collider.GetComponent<Hurtbox>();+if (hurtbox != null) {+    IDamagable damagable = hurtbox.characterBehaviour;     damagable.TakeDamage(weaponData.damage); }

The new component does almost nothing:

public class Hurtbox : MonoBehaviour {
    [field: SerializeField] public CharacterBehaviour characterBehaviour { get; private set; }
}

The tag check came out altogether.

By this point damage already worked. What changed is that the relationship between a collider and the thing it damages is now declared in the prefab and visible in the inspector, instead of being inferred at runtime from a tag string and a component search. No tag dependency, no runtime type lookup, and no silent path when the scene data doesn't match what the code assumed.

The pattern

Three bugs, and what separates them is how much each one told me. Loudest first:

  • Crash — the weapon array. Announces itself, hands you a line number, trivial once you've read it.
  • Error — the untagged colliders. Real signal, buried in noise. So I cut the noise; that's what the cubes were for.
  • Silence — the signature drift. No signal at all. Reduction couldn't help me here, because there was no symptom to clear the noise around. The only thing that worked was reading the path and refusing to assume that a method with the right name had a body worth running.

A crash points at itself. An error points roughly at itself. Silence points nowhere, which is why the third category is the one that ends projects — and it's also the only one you can do something about structurally.

Making failures louder

Those three categories aren't fixed, and that's the part I'd most want taken from this. Both of the last changes I made were the same move: dragging a failure out of the quiet category into a louder one.

Deleting the empty interface body took the signature drift out of the silent category permanently. Not easier to find — impossible to repeat. Two declarations that disagree now fail at compile time, which is as loud as a failure gets. The empty body was never the bug; it was what gave the bug permission to be silent. It was also still sitting there after damage started working again, which is why I went back for it.

The Hurtbox did the same thing for the scene-data category. A collider's damage target is declared in the inspector rather than inferred from a tag at runtime, so a mismatch is visible before you press play instead of absent after it.

Neither of those fixed anything that was broken at the time. They changed what the code is capable of failing at quietly. That's the difference between fixing a bug and being trusted with a codebase, and it's why "found it and fixed it" is the smaller half of the job.

The whole thing was one method body away from working. Nothing, anywhere, told either of us that.


Current state: not a finished game. Zombies spawn once instead of continuously, they crowd the player, and there's no muzzle flash or hit feedback, so shooting still doesn't read properly. Those are game problems, and this was never a post about a game.

The cubes are staying.

I'm Samuel Piggott, a Unity/C# developer working in Unity since 2013. Most of my work is inside existing projects — building into them safely, and finding out why something's broken when nobody knows. I've also taught programming at Goldsmiths, University of London, up to Masters level.

Got a Unity project doing something you can't explain? Send me a short description of what it does and when it started.

Get in touch