2.2 — Spikes: Physics, Damage & Death
You built a coin you can walk through. Now build a spike you cannot: one checkbox, and it breaks your game — because it reveals something you have been carrying since 1.4. Your character is a ghost.
Take damage when I touch a spike — for a character that is currently a ghost: the physics engine knows it is standing there and has never once been asked to move it.
First, before any code: cut that into parts small enough that you could look each one up on its own.
- Give the spike a shape that pushes backA Collider2D on the Spikes prefab, sized to the art, with
Is Triggerleft off, on the object carrying theSpikestag.Read: theOnCollisionEnter2D(Collision2D c)row in the quick reference, and theIs Triggersetting you used on the coin in 2.1. - write this partRead: the
rb.linearVelocity = vrow and the Body Type warning in the quick reference, and the movement table earlier in this lesson. - Confirm the thing you hit was a spike
OnCollisionEnter2D(Collision2D collision)fires when solid colliders meet, and its parameter is aCollision2D— a container, not a collider — which is why the tag is asked ofcollision.gameObjectrather than ofcollisionitself.Read: theOnCollisionEnter2D(Collision2D c)andother.CompareTag("Coin")rows in the quick reference. - Take the damage, and stop at the floorHealth is a class-body field, and one method changes it by a signed amount so the same method can heal later. The subtraction is guarded: if health is already zero, return before touching the number, so it can never run to -1, -2, -3 and stop meaning anything.Read: the
if (health <= 0) { ... } else { ... }row in the quick reference, and the coin count field you declared in 2.1.
Note which part is the gap and which three are last lesson's coin pickup wearing a different tag. What is genuinely new is who moves the Player — and until that changes, a solid spike is a picture you can stand inside.
The test: does the part name the thing that does the work? "Take damage" is an effect, not a part. "Hand the movement to the physics engine" is a part — and it is the only one here that changes what your character is allowed to do.
Your turn
Your coin count only ever goes up, so it needs no rule at all. Health only goes down, and it has a floor. Which part above keeps health inside the range where the number still means something — and what would the Console print on a second spike without it?
-
Hierarchy → select the Overworld scene's contents, then
drag two or three
Assets/Prefabs/Spikesinstances out of the Project window and into the Scene view. The scene ships with no spikes placed at all. -
Project window →
Assets/Prefabs/Spikes. Select the prefab asset, then Inspector → Add Component → Box Collider 2D. Drag the size handles until the green outline matches the art. - Inspector → Box Collider 2D → Is Trigger ☐ OFF. Leave it off: this is the one setting that separates a spike from a coin, because a push is the only thing that can stop you.
-
Project window →
Assets/Prefabs/Player, then Inspector → Rigidbody 2D → Body Type = Dynamic. It is already Dynamic from 2.1. Leave it. -
Inspector → Rigidbody 2D → Constraints → Freeze Rotation Z ☑ ON. In 2.1
you left Constraints alone because nothing in the world was solid to you. An off-centre
push imparts torque, and without this your character tumbles like a dropped coin the
first time it hits a wall at an angle.
The Rigidbody 2D from 2.1, scrolled down. Constraintsis a foldout at the bottom — click it open andFreeze Rotationis the second row under it, with a singleZbox because a 2D body cannot rotate in the other two axes. - Press Play and walk into your spike. You walk straight through it. That is the bug this whole lesson exists to fix.
You read this table in 1.5 as a prediction. Now it is a decision you live with for the rest of the year — so read the last two columns, and the warning underneath, because someone already fell into it for you.
| Technique | How it works | Stops at walls? | What it requires |
|---|---|---|---|
transform.Translate(vector) |
Offsets coordinates directly. Never consults physics. | No | Nothing. This is what you have now. |
Rigidbody2D.linearVelocity = vector |
States how fast the body is travelling; the physics engine performs the motion. | Yes | A Rigidbody2D with Body Type Dynamic. Movement is in units per second, so no Time.deltaTime. |
Rigidbody2D.MovePosition(position) |
Sweeps the body to a target position and tests the path as it goes. | Yes — but only on a Dynamic body | A Dynamic Rigidbody2D — the opposite of what the docs recommend. |
Rigidbody2D.AddForce(vector) |
Applies a physical push; mass, drag and inertia all apply. | Yes | A Dynamic Rigidbody2D with a mass you will have to tune. |
MovePosition(position) says it "is intended for use with
kinematic rigidbodies" — and the same page admits that on a kinematic body
"collisions won't affect the rigidbody itself". The manual goes further: a
Kinematic body "only collides with Dynamic Rigidbody 2Ds." On a Kinematic body you
arrive wherever you asked and nothing stops you on the way. The rule that
survives all of it: for anything to stop you, your body must be Dynamic —
which is why this course uses linearVelocity.
Choose, Then Justify
- Your character is a ghost. Which row fixes that with the smallest change to the code you have already written?
- This game is about exploring a world and being shot at by turrets. Do you want your movement to carry momentum? Which row gives you momentum whether you want it or not?
- Only one column in that table has the same value in every row that works. What is it, and why is that column the real decision rather than the technique you picked?
The question this answers“how do I stop my character walking through walls?”
You are editing Assets/Scripts/PlayerController.cs. The shape of your file stays exactly as it is: fields in the class body, input read in Update(). Only the lines that change what moves the character are yours to write.
This build leaves 1 line for you to write. Everything around it is given, so you always know the shape of what you are producing.
Step 1Declare a reference to the body
You gave the Player a Rigidbody2D in 2.1. To hand it instructions you need a reference to it.
// PlayerController.cs — class body, above Start(), with your other fields.
private Rigidbody2D body;- 2A field to hold the Player's Rigidbody2D component.
privateand no[SerializeField], because nothing outside this script needs to see it.Class body, not inside a method — the same rule from 1.3. This reference has to still be here on the next frame, so it belongs to the object.
Step 2Look it up once, in Start()
GetComponent<T>() is a search. Assign it once in Start() and remember the answer.
// PlayerController.cs — inside Start(), with your 1.6 lookups.
body = GetComponent<Rigidbody2D>();- 2Find the Rigidbody2D sitting on this same GameObject and store it in
body.GetComponent<T>()looks a component up on the object this script is attached to.Once, inStart()— never inUpdate(). Calling it every frame is sixty searches a second for an answer that never changes — the same "decide once, remember it" instinct as declaringinputXin the class body.
Step 3Replace one line in Update()
Everything above the gap is the vector you built in 1.4, unchanged. One line at the bottom of Update() is different.
// PlayerController.cs — inside Update().
inputX = Input.GetAxis("Horizontal");
inputY = Input.GetAxis("Vertical");
Vector2 direction = new Vector2(inputX, inputY);
direction = direction.normalized;
body.________________ = direction * moveSpeed;- 2Unchanged from 1.5.
- 5The two axis values packed into a vector. This is a direction: it says which way, not how far.
- 6Unchanged from 1.5 — keeps diagonal movement from being faster.
- Your turn8Hand the body a velocity instead of moving the Transform yourself. This one line replaces
transform.Translate(movement), and it is the whole fix.The property onbodythat says how fast it is travelling. In Unity 6 it is no longer calledvelocity.Two things changed at once, and both matter. First, you are asking the physics engine to do the moving — so it knows where you are going and can stop you. Second, there is noTime.deltaTime: velocity is already measured per second, so the engine applies the time for you. Multiplying byTime.deltaTimehere is the most common mistake on this line.
Check your work: the finished filetry every gap first — then open this to compare
Compare against your own file rather than copying it. The gap is the linearVelocity line: there is no deltaTime because velocity is per second, and the frozen rotation exists because solid collisions push.
// ------------------------------------------------------------------
// REFERENCE FILE — Assets/Scripts/PlayerController.cs
// Written to be read, not pasted. Build it with the walkthrough
// above, one step at a time, then open this and compare.
// If it is sitting in your project unchanged, that is the thing
// to go and do now — the walkthrough is still above you.
// ------------------------------------------------------------------
using UnityEngine;
public class PlayerController : MonoBehaviour
{
// ---- class body: every field lives up here, above Start() --------------
[SerializeField] private float moveSpeed = 5f;
[SerializeField] private int health = 3; // <- new in 2.2
[SerializeField] private int coins; // from 2.1
private Rigidbody2D body; // <- new in 2.2
private float inputX;
private float inputY;
private Animator animator; // from 1.6
private SpriteRenderer spriteRenderer; // from 1.6
// ---- methods you own --------------------------------------------------
void Start()
{
body = GetComponent<Rigidbody2D>(); // <- new in 2.2
animator = GetComponentInChildren<Animator>();
spriteRenderer = GetComponentInChildren<SpriteRenderer>();
}
void Update()
{
inputX = Input.GetAxis("Horizontal");
inputY = Input.GetAxis("Vertical");
Vector2 direction = new Vector2(inputX, inputY);
direction = direction.normalized;
// No Time.deltaTime: velocity is per second, not per frame.
body.linearVelocity = direction * moveSpeed;
}
void UpdateAnimation()
{
// unchanged from 1.6
}
// ---- callbacks: Unity calls these, you never do -----------------------
private void OnCollisionEnter2D(Collision2D collision)
{
// Collision2D, not Collider2D — so the tag is asked of .gameObject.
if (collision.gameObject.CompareTag("Spikes"))
{
TakeDamage(1);
}
}
private void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("Coin"))
{
coins = coins + 1;
Debug.Log("Coins collected: " + coins);
Destroy(other.gameObject);
}
}
private void TakeDamage(int amount)
{
if (health <= 0)
{
return;
}
health = health - amount;
Debug.Log("Health: " + health);
if (health <= 0)
{
Debug.Log("Player died.");
}
}
}This one is on you, in the same shape as the walkthrough above — no gap-by-gap guide, because every piece of it you have built once. A field in the class body is 2.1. A method that changes a value is 1.3. A callback that checks a tag is 2.1. Specification:
- A field on
PlayerController, besidecoins— what type, what starting value, and how is it exposed as a tuning knob? - One method that changes health by a signed amount, so a later heal is the same call with a different sign. Why one method rather than separate heal and damage methods?
- Which callback holds the spike check, and how do you identify the spike from inside it — given the parameter is a
Collision2Dand not a collider? - What condition means "already dead", and what should the method do at that exact moment?
- After subtracting health, what do you print so a player can watch the number change with no UI at all?
A spike is solid, so it stops you. "Stopping you" and "hurting you" are two separate decisions, and there are three honest ways to build the hazard:
| Approach | What the player feels | What fires | The risk |
|---|---|---|---|
| Solid spike | You stop against it. Damage once per touch. | OnCollisionEnter2D(Collision2D collision) |
You can get wedged against it, and sliding along it may not register at all. |
| Trigger spike | You walk through it. Damage once per entry. | OnTriggerEnter2D(Collider2D other) |
Standing inside it deals no further damage — usually wrong for a hazard. |
| Trigger spike + cooldown | Repeated damage while you stand in it. | OnTriggerStay2D(Collider2D other) |
It fires every frame. Without a cooldown you lose all your health instantly. |
Death has more than one defensible design. Pick one and defend it:
(a) reload the current scene with
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex) — everything
resets, coin count included; (b) reload a scene by name — which scene, and
does it contain a Player?; (c) do not reload at all — reset health, move
the player back to the start, keep the coins.
Your Decision Record
- Which Rigidbody 2D settings did you have to change, and what did you see when one of them was wrong?
- What evidence from your own build proves your choice works — what did you run into, and what happened?
Checkpoint — before moving on, you must be able to:
- Explain why
transform.Translate(vector)ignores colliders, and quote the documentation saying so - Compare the four movement methods, and name the one setting they all depend on
- Explain why
linearVelocitydoes not get multiplied byTime.deltaTimewhentransform.Translate(vector)did - Explain
OnCollisionEnter2D(Collision2D collision)versusOnTriggerEnter2D(Collider2D other)by their parameter types - State exactly what resets and what survives when your player dies
- Commit and push
Reference Docs
Need a deeper explanation? These pages cover the same concepts:
- Triggers vs Collisions — the complete reference for this physics family, used all year
- Unit 2: Stats & Pickups — colliders, health and death in depth
- Unity + C# Quick Reference — Rigidbody2D and physics callbacks
- How do I…? index — find this lesson again by the question you were asking
Next: 2.3 — Healing Pickups & the Max-Health Cap, where a value with a ceiling needs a rule, not just arithmetic.
Saved on this device only — the Dashboard tracks your progress.