◀ 5.15.25.3 ▶

5.2 — The Door: Opening It with Events

You have an axe, and the cave door does not care — yet. Today you close the last gap: press E beside the lever, and a door the lever has never met swings open. The lever knows nothing about doors. It flips a sprite and it fires an event, and who is listening is somebody else's business. That gap between cause and effect is called abstraction, and it is the most exam-relevant idea in this whole project.

In this lesson you will be able to answer“how do I open a door with a key?”Full walkthrough
Break It Down~10 minFull walkthrough

Press E beside a lever, and a cave door you have never touched disappears - the wall you could feel, the wall you could see, and a panel that has nothing to do with any of it, all switched off at the same instant by one object that only ever flips a sprite.

First, before any code: cut that into parts small enough that you could look each one up on its own.

  1. Notice one press of a keyInput is a state, not a moment: Input.GetKey(...) is true on every frame the key is held, and Input.GetKeyDown(...) is true on exactly one frame. An interaction wants the one frame, or a single press becomes sixty.Read: the Input.GetKeyDown(...) row of the quick reference, and the Input.GetMouseButtonDown(0) you wrote for the attack in 1.6.
  2. Decide the player is close enoughA straight-line distance between two positions, compared against a number you chose and put in the Inspector so a designer can change it.Read: the Vector3.Distance row of the quick reference, and the range check you wrote on the turret in 3.1.
  3. Find the lever without knowing its nameA scene-wide search for a Lever component — Object.FindFirstObjectByType<Lever>() in Unity 6, which replaces the deprecated FindObjectOfType<T>(). It hands back the component, not the GameObject, and it hands back null when the scene has no lever in it at all.Read: the GameObject.FindWithTag("Coin") row of the quick reference, and the same search on the turret in 3.1.
  4. Ask the lever to act, without doing its job for itOne call: lever.Interact(). The lever owns its own state, its own sprite swap and its own Invoke(); the player contributes a moment in time and nothing else. This is why Interact() is public and almost everything else in that file is not.Read: Assets/Scripts/Lever.cs itself — public void Interact(), and the comment above it that already says who calls it.
  5. Let the lever announce that it happened, without naming anyoneonLeverOn.Invoke() runs every method that has subscribed, and the lever holds no reference to a single one of them. That gap is what your exam calls abstraction: a procedure causing other procedures to run without knowing their names.Read: the two UnityEvent fields at the top of Lever.cs, and the On Lever On list on CaveSwitch — three subscribers the lever has never met.
  6. Subscribe to the announcement, and open the doorAddListener puts your method on the event and RemoveListener takes it off again; one without the other is how a listener outlives the object that added it. The field is private, so the Inspector can subscribe to it and C# cannot.Read: Lever.cs again — the access level on onLeverOn — and the three listeners already sitting on CaveSwitch, which is exactly what that field was left private for.

Four of those six parts are work you have already done: a key that fires on one frame is 1.6, a distance measured against a number is 3.1, and a search plus a method call is what you have been writing since 2.1. The last two have no precedent anywhere in this course, and they are the reason this lesson is a full walkthrough rather than a guided one. Every part before today had a name you could look up. An event does not: Invoke() is the first line in this project that causes code to run without naming the code, and the only way to learn a mechanism you cannot search for is to be shown the whole of it once.

The test: could you look this part up on its own, right now, in one search? "Open a door with a key" cannot be looked up — there is no such API. "Make a method run when an event fires" can: that is AddListener(...), one search away.

Your turn

The lever is driving three subscribers today and has never met any of them. Say what it would have had to know if it had been written the obvious way — holding a reference to the door and calling a method on it — and say what the event removed from the file.

The event that connects the lever to the door is private. Which of the two roads this lesson builds — the Inspector list, or AddListener(...) in a script — works without editing somebody else's file, and what does the other road cost you?

In the Editor ~20 min
  1. Project window → Assets/Scripts/Lever.cs — the one script in that folder which is not an empty shell. Read the whole thing; it is short. Two fields at the top are UnityEvents, and the word in front of both is private. Then notice what is missing: the word "door" appears nowhere in that file. The lever has no idea what it is switching on.
  2. Hierarchy → Overworld → CaveSwitch — the object standing in the scene, not the prefab in the Project window — then Inspector → On Lever On. Somebody has already wired it up: three listeners, each one calling SetActive with a true/false argument. Those listeners are prefab-instance overrides, so the prefab asset's own lists are still empty — inspecting the prefab and seeing nothing wired is not a bug.
  3. Inspector → expand the first two listeners: they target Collision Door Removed and No Collision Door Removed. Find both in the Hierarchy and look at them in the Scene view. Together they are the door — one object for the wall you can feel, one for the wall you can see, switched off at the same moment. The door mechanism already works.
    Inspector showing CaveSwitchInspectorCaveSwitchTransformLever (Script)On Lever On3Collision Door RemovedSetActive (bool)falseNo Collision Door RemovedSetActive (bool)falseCanvasSetActive (bool)falseOn Lever OffAdd Component
    Inside Lever (Script), the two event lists. A listener is two decisions and an argument: which object, which method, and what that method is handed — and every entry below calls SetActive with false, because the lever's job is to remove things.
  4. Inspector → expand the third listener: it points at the scene's Canvas, which nothing in this scene uses yet, with no note explaining why the lever turns it off. That is your first piece of genuine legacy wiring. Then expand On Lever Off: it is empty. (If the list shows five rows rather than three, two are leftover blank entries from when it was made shorter — they target nothing and never fire.)
  5. Game view → press Play, walk to the lever, and press E. Nothing happens. The lever is fully wired to the door, and no line in your controller has ever asked about the E key — that is the half you are about to write.
  6. Once both walkthroughs are done — Game view → Play, walk to the lever, press E. The wall vanishes: a door somebody else built, opened by the interaction you wrote, with nothing about the door in your code and nothing about your code in the door. That is the whole lesson in one key press.
  7. Inspector → On Lever On → add a fourth listener of your own choosing — reveal a message, swap a sprite, disable a hazard. Everything that list can call is a public method or property on some component, which is the same constraint your own scripts live under: zero lines of code, and the game has a new rule.
In the Code ~35 min

Every row below is a call you will type in the next two walkthroughs. Four are calls you have met in another lesson; two are the new mechanism.

APIWhat it gives youWhat it costs
Input.GetKeyDown(KeyCode.E) True on the single frame the key goes down — the moment rather than the state, and the keyboard twin of the GetMouseButtonDown behind your attack in 1.6. Choosing the wrong twin is invisible in the code and obvious in the game: Input.GetKey(KeyCode.E) is true on every frame the key is held, so one press becomes sixty.
Object.FindFirstObjectByType<Lever>() A scene-wide search that hands back the first Lever component, without you ever holding a reference to it. The Unity 6 spelling — the old FindObjectOfType<T>() is deprecated. It searches the whole scene: fine once per key press, wasteful once per frame. And it hands back a component, never a GameObject.
Vector3.Distance(a, b) The straight-line distance between two points — the call that decided whether the turret could see you in 3.1, now deciding whether you can reach the lever. It measures between two positions, so both have to exist. Ask it about a null reference and the script stops, every frame, pointing at that line.
lever.transform A component's shortcut to the Transform of the object it is attached to — how a search that returns a component still gives you a position. Nothing. A component has no position of its own; it always reaches through its own object.
lever.Interact() — and the word public The lever's one offered method. The player contributes a moment in time; the lever does everything else. It has to be public. A private method is visible only inside its own class, so the compiler refuses the call outright — error CS0122. That is the difference between a method that is used and one that is offered.
onLeverOn.Invoke()
onLeverOn.AddListener(m)
onLeverOn.RemoveListener(m)
The cause, and the two ways to attach an effect. Invoke() runs every method that has subscribed and names not one of them; AddListener(m) hands your method over as a value, with no parentheses on it. The event accepts only methods that take nothing and return nothing, so the shape of your handler is not yours to choose. And a listener you never remove outlives its object — the next Invoke() calls into something that has been destroyed.
Architectural specification — the interaction you built:
  • The moment. Which input call fires on a single frame, and what does the lever do if you use the one that fires on every frame instead?
  • The search. What does the search hand back — a component or a GameObject — and what does it hand back in a scene with no lever in it? What happens on the next line if you do not check for that?
  • The reach. What does "close enough" mean numerically, where does that number live so a designer can change it, and which comparison operator did you pick at the boundary?
  • The call. Why must Interact() be public for your line to compile — and what does your own script know about the lever's state after it calls it?

Socratic: Where does the game's knowledge live?

  1. The lever opens a door it has never met. Which window do you open to teach it about the door — and what does your answer say about where the game's rules actually live?
  2. If the lever had been written the obvious way — holding a reference to the door and calling door.Open() directly — what would you have to change to make a second lever open a second door? What does the event version cost instead?
  3. The exam calls a procedure that calls other procedures without knowing their names abstraction. What would the lever have needed to know without UnityEvent, and what complexity does the event remove?
In the CodeFull walkthrough

The question this answers“how do I press E beside a lever and make the lever do something?”

You are editing Assets/Scripts/PlayerController.cs. The lever is already wired to the door. What is missing is any way for the player to reach it: press E today and nothing happens, because no line in your controller has ever asked about the E key. The whole interaction is about fifteen lines, and every one is given and explained below.

Step 1Declare how far your reach is

One field, in the class body, beside moveSpeed and the rest: the [SerializeField] private pattern from 1.3 — private in code, a knob in the Inspector.

    [SerializeField] private float interactRange = 1.5f;
  1. 1How close counts as near enough, in world units. 1.5f is roughly one player width — a sane first guess for a lever standing on the ground.It has to be a field, not a number typed into the condition. A number written into the if is invisible: "I have to stand exactly on top of the lever" then means editing code and waiting for a recompile. As a serialized field it is a slider, and every playtest becomes a drag instead of a build. The same argument as moveSpeed in 1.4, and the same as the threshold you tuned on the turret in 3.1.

Step 2Notice the press - once, not sixty times a second

These four lines go at the bottom of the Update() you have been adding to since 1.4, after your movement and animation lines. The method they call is written in step 3, so the file will not compile until step 5 — type all five steps, then build.

        if (Input.GetKeyDown(KeyCode.E))
        {
            TryInteract();
        }
  1. 1The new key question. Input is the class from 1.4, KeyCode.E names the key, and GetKeyDown is true on the single frame the key goes down.Input.GetKey(KeyCode.E) is the wrong one here, and it fails in the loudest possible way. Without the Down it is true on every frame the key is held, so half a second of holding E toggles the lever about thirty times and the door opens and shuts until you let go. The lever looks broken; it is not, the condition is. The exact pair you met on the mouse in 1.6.
  2. 3Calls the method you write in step 3. The parentheses are empty because it needs nothing handed to it: interactRange is a field, and the lever is found inside the method.A method you write yourself does nothing until something calls it — the rule you met in 1.6 with UpdateAnimation() and CheckAttack(). Leaving this line out is the quietest bug in the lesson: no error, no warning, nothing.

Step 3Find the lever, and survive finding nothing

A method of your own, and its first job is to answer "which lever?". You have never held a reference to it and you never will — it is one object standing somewhere in a scene, and your script has no slot for it.

    private void TryInteract()
    {
        Lever lever = Object.FindFirstObjectByType<Lever>();
        if (lever == null)
        {
            return;
        }
  1. 1private, because nothing outside this script calls it — compare Interact() on the lever, which is public for exactly the opposite reason.The interaction is its own method so Update() stays readable as three ideas plus one new one: the same separation you made for UpdateAnimation() in 1.6.
  2. 3Searches the whole scene for the first Lever component and stores it. Two things to read carefully: the type is Lever, the component, not GameObject — and FindFirstObjectByType is the Unity 6 spelling.Object.FindObjectOfType<Lever>() is the old spelling and Unity 6 marks it deprecated. It still runs and still finds things, which is why the mistake survives: what you get is a Console carrying an obsolete-call warning on every compile, and a line the docs have retired.
  3. 4The guard. The search hands back null — "nothing at all" — when the scene contains no lever, and this line catches that before anything tries to use it.The Overworld has one lever; the other scenes have none. Start (index 0), Platformer, and any empty test scene you press Play in contain no Lever at all, so the search returns null there every single time. Without this guard the next line throws a NullReferenceException into the Console every frame E is pressed — in a scene that looks like it has nothing to do with the lever you are working on, which is what makes it take an hour to find.
  4. 6Leaves TryInteract() immediately. Nothing below runs, and Update() carries on as normal — the player still walks, and E simply does nothing.

Step 4Measure the distance, and refuse if you are too far

The same measurement the turret made in 3.1, with two differences: it is between the player and the lever, and the number it is compared against is the field from step 1.

        float distance = Vector3.Distance(transform.position, lever.transform.position);
        if (distance > interactRange)
        {
            return;
        }
  1. 1The straight-line distance from the player to the lever, as a float. On the left, transform.position is yours — this script sits on the Player. On the right it is reached through the component you just found.lever.transform answers the component-versus-GameObject question. A component has no position of its own; every MonoBehaviour carries a transform property pointing at the Transform of the object it is attached to. So the search handing back a component costs you nothing.
  2. 2True when the lever is further away than your reach — the refusal rather than the permission, because that is how the guard below is written.> decides what happens at the boundary. distance > interactRange refuses anything past your reach and allows standing exactly at it; >= would refuse the edge itself. Neither is wrong — pick one and know which. The same decision you made about the arrival threshold in 3.1.
  3. 4Leaves TryInteract() again: you found a lever and decided it is not one you can reach, so there is nothing left to do this frame.

Step 5Tell the lever

One line, and it is the only thing your script ever says to the lever. Everything else about the lever — its state, its sprite, what else should happen — is not your business and never appears in your file.

        lever.Interact();
    }
  1. 1Calls the lever's own public method. The parentheses are empty because the lever needs nothing from you: it already knows whether it is on or off, and it holds its own sprites.Interact() has to be public or this line will not compile. private means "visible only inside this class", so a private Interact() would be refused the moment another script named it — error CS0122. The lever marks it public because the lever expects to be called from outside: that is the difference between a method that is merely used and a method that is offered.
Check your work: the finished filebuild it as you read — then open this to check every line

Compare against your own file rather than copying it. What has to match is the shape of the interaction, not its text: a single-frame key check, a scene search whose result is allowed to be nothing, a distance measured against a serialized number, and one call to Interact(). This version folds both refusals into one && condition, where the walkthrough used two separate early returns — on every frame the two files do the same thing.

// ------------------------------------------------------------------
//  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;

// PlayerController.cs - the interaction half, complete. Everything you built
// from 1.4 to 2.2 (input, movement, animation, coins, health) stays exactly as
// you wrote it; the two members below are the whole of what is new.
public class PlayerController : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;          // your number from 2.2
    [SerializeField] private float interactRange = 1.5f;    // how close is "near enough"

    private Rigidbody2D body;

    void Start()
    {
        body = GetComponent<Rigidbody2D>();                 // looked up once, per 2.2
    }

    void Update()
    {
        // ... the input, movement and animation lines from 1.4, 1.6 and 2.2 ...

        if (Input.GetKeyDown(KeyCode.E))
        {
            TryInteract();
        }
    }

    // One search, one measurement, one call. The search is allowed to come back
    // empty, so the two refusals are folded into the same condition - which
    // leaves exactly one line in this file that can do anything at all.
    private void TryInteract()
    {
        Lever lever = Object.FindFirstObjectByType<Lever>();

        if (lever != null &&
            Vector3.Distance(transform.position, lever.transform.position) <= interactRange)
        {
            lever.Interact();
        }
    }
}
In the CodeFull walkthrough

The question this answers“how do I make my own object open the door when the lever is pulled?”

You are editing Assets/Scripts/DoorOpener.cs (plus one line of Lever.cs). The Inspector list on CaveSwitch already opens the cave door — that is the road you just used, and it needs no code and no edits to anybody else's file. This walkthrough builds the same connection in a script of your own, which is the road you take when the thing you want to happen is not already a listener. Both roads end at the same door.

Step 1Open the event up - the one edit to somebody else's file

Read the two lines at the top of Lever.cs again. The events are [SerializeField] private — and that one word, private, is the whole reason the Inspector was the only road available.

    // BEFORE:  [SerializeField] private UnityEvent onLeverOn;
    public UnityEvent onLeverOn;
  1. 1A comment, so you can see what you are changing. Nothing else in Lever.cs moves today: not Interact(), not the sprite swap, not the Invoke().
  2. 2The same field with one word changed: delete private and the [SerializeField] in front of it, and write public.private means only code inside Lever may touch this field — and AddListener(...) IS touching it. Your DoorOpener is a different class, so the compiler refuses with error CS0122, and there is no way around it from your side. [SerializeField] is what made the field visible in the Inspector while staying private to C#: Unity's serialization can reach a private field and your code cannot, and the Inspector is not code. So the field was reachable by the three listeners somebody wired and by nobody else — and that was almost certainly deliberate. Exposing it says "any script may now subscribe to this", and that sentence is the actual cost. If you also want to subscribe to the switch-off, make the identical change to onLeverOff.

Step 2Declare what this script is listening to

A brand new file in Assets/Scripts/. It has two slots to fill in the Inspector and no other state at all — a listener owns nothing, it only reacts.

    [SerializeField] private Lever lever;
    [SerializeField] private GameObject door;
  1. 1A slot holding the lever component this door listens to. Lever is the type name from Lever.cs, so the field can hold nothing else — and an empty slot is a bug you can see before you press Play.A wired reference rather than a scene search. You could call FindFirstObjectByType<Lever>() in Start() here and it would work. The slot is better for this job: a door that listens to a specific lever should say so where a designer can see it. Same argument as the waypoint slots in 4.1 — a reference to a thing in the scene belongs to the scene.
  2. 2The object to switch off when the lever is pulled — the same Collision Door Removed object the Inspector listeners target, or one you made yourself.GameObject, not Transform. The call the listeners make lives on the GameObject, so the slot has to be able to hold one. A Transform slot would give you door.gameObject.SetActive(false) and one extra hop for nothing.

Step 3Subscribe in Start()

This is the line that makes the whole mechanism work: your method is handed to the lever's event, and from now on the lever calls it whether it knows your name or not.

    void Start()
    {
        lever.onLeverOn.AddListener(OpenDoor);
    }
  1. 1The once-only setup method from 1.3 — the same one that finds your body and seeds your memory in every other script you own. Here it does the subscribing.Once, and only once. Put the subscription in Update() instead and you add a new listener every frame: after one second the lever has sixty subscribers, and one pull calls your method sixty times. It still looks like it works, because switching a door off twice is the same as switching it off once — which is what makes that bug so easy to ship.
  2. 3Adds OpenDoor to the lever's event. Read it left to right: the lever, its event field, the method to add. Notice there is no dot between AddListener and OpenDoor — OpenDoor is not being called here, it is being handed over as a value.This is the whole point of the lesson in one line. The lever stores a reference to your method and calls it later. It does not check what the method does, where it lives, or what the object is for: OpenDoor could equally start music or count a coin, and Lever.cs would not change by a character. That is what your exam means by abstraction, and it is what the three listeners on CaveSwitch are doing already.

Step 4Write the method that actually opens the door

A plain method with an empty parameter list — and the emptiness is not an accident. It has to fit the shape the event is expecting: a method that takes nothing and returns nothing.

    private void OpenDoor()
    {
        door.SetActive(false);
    }
  1. 1private, because nothing outside this script calls it — the lever calls it through the subscription, and the subscription was set up in here. No parameter and no return value, which is the only shape onLeverOn can accept.An event fixes the shape of the methods it will call. AddListener(...) wants a method that takes no arguments and hands nothing back, so OpenDoor(int n) or an int OpenDoor() would be refused at compile time. That constraint is the price of the lever being able to call you without knowing anything about you.
  2. 3The one thing this script does: switch the door object off, the same call the Inspector listeners make. false means inactive — the object still exists, it is simply not in the world any more.This is the line the Inspector list has been running all along. Open one of the three listeners on CaveSwitch and you will see the same call, with the same false, wired to a different object. You have not built anything new here; you have built the same thing somewhere you can read it, which is the point of the exercise.

Step 5Unsubscribe in OnDestroy()

The line everybody forgets. Start() put your method on the lever's event, and the lever will keep calling it for as long as the event exists — whether or not the object that subscribed is still there.

    private void OnDestroy()
    {
        lever.onLeverOn.RemoveListener(OpenDoor);
    }
  1. 1Another lifecycle method, like Start() and Update() — but Unity calls this one when the object is about to be destroyed. Scene changes, Destroy(gameObject), and the end of Play mode all land here.The pair to Start(). Every subscription has a matching unsubscription, and Unity runs the two at the two ends of an object's life. Once you know to look for the pattern you will see it everywhere: whoever adds a listener is responsible for removing it.
  2. 3Takes OpenDoor back off the event. Same three pieces as the AddListener(...) line, with the same method, in the same order.A listener that outlives its object is a call into something that is no longer there. The event is holding a reference to your object, and if you are destroyed while still subscribed, the next pull of the lever calls a method on a destroyed object and Unity throws a MissingReferenceException. You will meet this properly in 5.3, when objects start outliving scenes. This two-line pair is the smallest version of being careful.
Check your work: the finished filebuild it as you read — then open this to check every line

Compare against your own file rather than copying it. Three things carry the lesson, and all three have to match: the access level you changed on onLeverOn, the subscription in Start(), and the unsubscription in OnDestroy(). This version keeps the method in a UnityAction field and hands the same value to both calls, where the walkthrough passed the method group straight into each one — the walkthrough's form is correct and shorter, and this is the form you reach for once there are several methods to remove.

// ------------------------------------------------------------------
//  REFERENCE FILE — Assets/Scripts/DoorOpener.cs (plus one line of Lever.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;
using UnityEngine.Events;

// DoorOpener.cs - this script's whole job is to be listening when the lever is
// pulled. It never touches Lever.cs at runtime: the lever calls OpenDoor without
// knowing this file exists, and it would call anything else the same way.
public class DoorOpener : MonoBehaviour
{
    [SerializeField] private Lever lever;        // the lever this door listens to
    [SerializeField] private GameObject door;    // the thing that disappears

    // The same method, kept as a value. AddListener and RemoveListener have to
    // be handed the SAME delegate, and holding it in a field is what makes the
    // removal exact rather than hopeful - and it is the only way once there is
    // more than one method to take off.
    private UnityAction onLeverPulled;

    private void Start()
    {
        onLeverPulled = OpenDoor;
        lever.onLeverOn.AddListener(onLeverPulled);
    }

    private void OnDestroy()
    {
        // The pair to Start(). A listener that outlives its object is a call
        // into something that is no longer there.
        lever.onLeverOn.RemoveListener(onLeverPulled);
    }

    private void OpenDoor()
    {
        door.SetActive(false);
    }
}

Checkpoint — before moving on, you must be able to:

Reference Docs

Need a deeper explanation? These pages cover the same concepts:

Next: 5.3 — Into the Cave: Scenes, Persistence & The Refactor, where you leave the overworld and everything you have stored breaks — including the listener you wrote today.

0/5 answered

Answers save on this device as you type. At the end of class, click Download and upload the file to the Canvas assignment.

Saved on this device only — the Dashboard tracks your progress.