The Unity script needs the Editor and the setup listed below. The separate C# companion practice runs in SharpRune without UnityEngine.
Make selection, permission and scene mutation separate steps
A focused station interaction has three independent questions: what the player selected, whether the current state permits opening and what scene changes follow a successful transition. KeyedStationProbe keeps those steps visible. An E action triggers a camera ray; the nearest included nontrigger collider must be the station’s own collider. A pure state object then requires a key and range permission before consuming the key and becoming opened. Only after success does the shell disable the collider and hide its visual child. This is different from the earlier collection capstone because the centre of the lesson is command validation and one-way state, not movement or a timed score.
Pair input lifetime and validate the exact hit
Awake acquires the root BoxCollider, creates state and constructs the physical-keyboard InputAction. OnEnable subscribes and enables; OnDisable removes the same callback and disables; OnDestroy disposes the owned action. The handler checks activation, required references and a finite positive range before casting. It then compares hit.collider with stationCollider, so a child collider or nearer wall is not silently accepted as this station. State changes happen before scene effects, preventing another valid request from consuming the same one-way transition twice. Keeping the root alive also keeps ownership clear after the closed visual is hidden.
private void OnInteract(InputAction.CallbackContext context)
{
if (!isActiveAndEnabled || state == null || viewCamera == null || stationCollider == null) return;
if (range <= 0f || float.IsNaN(range) || float.IsInfinity(range)) return;
Ray ray = viewCamera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f));
if (!Physics.Raycast(ray, out RaycastHit hit, range, interactionLayers, QueryTriggerInteraction.Ignore)) return;
if (hit.collider != stationCollider || !state.TryOpen(true, hit.distance, range)) return;
stationCollider.enabled = false;
if (closedVisual != null && closedVisual != gameObject) closedVisual.SetActive(false);
Debug.Log("Station opened once; key consumed", this);
}Define the one-way transaction before wiring inventory
GateState starts with neither a key nor an opened result. GrantKey supplies at most the tutorial’s Boolean permission and does nothing after opening. TryOpen rejects invalid numeric inputs explicitly, then rejects an already opened state, absent key, missing focus or excessive distance without changing permission. At the exact maximum distance the range rule accepts. On success it consumes HasKey and sets Opened together before returning true. A real inventory with stack counts, stable item IDs, persistence or networking would need a richer transaction contract. The context menu is intentionally a local teaching grant, not a claim that any of those systems have been implemented.
public sealed class GateState
{
public bool HasKey { get; private set; }
public bool Opened { get; private set; }
public void GrantKey() { if (!Opened) HasKey = true; }
public bool TryOpen(bool focused, float distance, float maximum)
{
if (maximum <= 0f || float.IsNaN(maximum) || float.IsInfinity(maximum)) throw new ArgumentOutOfRangeException(nameof(maximum));
if (distance < 0f || float.IsNaN(distance) || float.IsInfinity(distance)) throw new ArgumentOutOfRangeException(nameof(distance));
if (Opened || !HasKey || !focused || distance > maximum) return false;
HasKey = false; Opened = true; return true;
}
}Trace rejection, consumption and idempotence independently
The first console attempt has focus and range but no key, so it returns False. After a key is granted, the unfocused attempt also returns False while preserving permission. The third attempt is exactly at the three-unit boundary and succeeds, returning True. A later close-range attempt returns False because the state is already opened, and HasKey prints False because success consumed it. These five results establish pure state behavior, not an actual input, camera or collision result. The separate multi-file companion project adds invalid-value and independence checks so those rules can be reviewed without any UnityEngine stand-ins.
Blocking masks and child ownership affect the real result
If a wall should block selection, its layer must participate in the ray query; excluding it permits the query to ignore it. Remove the Cube child’s default collider in the baseline so the intended root BoxCollider is the exact selected component. Keep closedVisual a separate child and never disable the controller root as a side effect of hiding the door. A missing key produces no opening, which is an expected rule rejection rather than an input failure. Focus Game view when testing the keyboard and verify the configured Input System backend. These setup facts explain failures that a passing state-model test cannot possibly discover.
Practice: verify the interaction matrix in a fresh scene
Test no key, granted key with no focus, a target beyond range, an included-layer blocker and one valid focused interaction as separate initial scenarios. Record both the root collider’s state and the child visual’s activation after the valid case. Repeat E and try granting another key after opening; neither should reopen the station. Then disable and re-enable the controller in a fresh closed scenario to inspect input subscription ownership. Keep the runtime root active and avoid treating the Play-mode Grant Key menu as a persistent inventory. A production adapter should obtain permission from its real authority while preserving the tested state transition boundary.
- Hint: check the exact root collider hit before investigating key consumption.
- Hint: a missing input callback, a rejected selection and a rejected state command need different diagnostics.
Practice: extend the transaction contract before adding effects
Run the pure boundary checks for zero or invalid maximum range, negative or nonfinite distance, exact boundary, repeated success and two independent stations. Then decide how a richer model would return a named rejection reason without changing state on a rejected command. Add an audio or animation request only after success, and give that effect its own lifetime owner rather than embedding it into the state class. The real Unity source was API-reviewed and syntax-checked as C#9; no local Editor, physics or Input System run is claimed. The executed portable programs establish their rule results only, leaving the complete interaction scene as an explicit integration exercise.
- Hint: test rejection leaves key state unchanged, not just that the result is False.
- Hint: scene effects should consume a successful decision rather than becoming hidden permission checks.
Unity Editor example
Set up in Unity 6
- Install a compatible com.unity.inputsystem package, set Active Input Handling to Input System Package (New) or Both, and restart when prompted. This script binds a physical keyboard E action, not a project-wide UI action.
- Create StationRoot with a nontrigger BoxCollider and KeyedStationProbe.cs. Add a visible Cube child called ClosedVisual; remove that child’s extra collider so the root BoxCollider is the intended exact hit.
- Assign ClosedVisual and a Camera facing the station. Place the camera ray origin outside colliders and within range three of the station collider; assign a mask that includes the station and any walls meant to block it.
- Enter Play mode, focus Game view and press E before granting a key: no opening should occur. Run Grant Key from the component context menu, refocus Game view and press E while the centre ray points at StationRoot.
- Observe one opening log, a disabled station collider and hidden ClosedVisual while StationRoot stays active. Repeat E, leave range, and insert an included-layer wall in separate fresh sessions to check boundaries.
- Do not assign StationRoot itself to closedVisual. A production inventory, animation, audio cue, save identity and network authority are separate extensions, not features claimed by this focused capstone.
KeyedStationProbe.cs
Download Unity script ↗using System;
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(BoxCollider))]
public sealed class KeyedStationProbe : MonoBehaviour
{
[SerializeField] private Camera viewCamera;
[SerializeField] private GameObject closedVisual;
[SerializeField] private LayerMask interactionLayers = ~0;
[SerializeField, Min(0.01f)] private float range = 3f;
private Collider stationCollider;
private InputAction interact;
private GateState state;
private void Awake()
{
stationCollider = GetComponent<BoxCollider>();
state = new GateState();
interact = new InputAction("Interact", InputActionType.Button, "<Keyboard>/e");
}
private void OnEnable() { if (interact != null) { interact.performed += OnInteract; interact.Enable(); } }
private void OnDisable() { if (interact != null) { interact.performed -= OnInteract; interact.Disable(); } }
private void OnDestroy() { if (interact != null) interact.Dispose(); }
[ContextMenu("Grant Key (Play Mode)")]
public void GrantKey() { if (Application.isPlaying && state != null) { state.GrantKey(); Debug.Log("Key available: " + state.HasKey, this); } }
private void OnInteract(InputAction.CallbackContext context)
{
if (!isActiveAndEnabled || state == null || viewCamera == null || stationCollider == null) return;
if (range <= 0f || float.IsNaN(range) || float.IsInfinity(range)) return;
Ray ray = viewCamera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f));
if (!Physics.Raycast(ray, out RaycastHit hit, range, interactionLayers, QueryTriggerInteraction.Ignore)) return;
if (hit.collider != stationCollider || !state.TryOpen(true, hit.distance, range)) return;
stationCollider.enabled = false;
if (closedVisual != null && closedVisual != gameObject) closedVisual.SetActive(false);
Debug.Log("Station opened once; key consumed", this);
}
public sealed class GateState
{
public bool HasKey { get; private set; }
public bool Opened { get; private set; }
public void GrantKey() { if (!Opened) HasKey = true; }
public bool TryOpen(bool focused, float distance, float maximum)
{
if (maximum <= 0f || float.IsNaN(maximum) || float.IsInfinity(maximum)) throw new ArgumentOutOfRangeException(nameof(maximum));
if (distance < 0f || float.IsNaN(distance) || float.IsInfinity(distance)) throw new ArgumentOutOfRangeException(nameof(distance));
if (Opened || !HasKey || !focused || distance > maximum) return false;
HasKey = false; Opened = true; return true;
}
}
}
What to observe in Unity
A correctly wired E press opens this exact focused station once after a key is granted and consumes that key. Missing focus, excessive range, a blocker or no key prevents the transition; later presses cannot reopen it.
The console practice below isolates the C# logic. It does not run a Unity scene or emulate Unity engine callbacks.
C# companion practice
using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
var gate = new GateState();
Console.WriteLine(gate.TryOpen(true, 2f, 3f));
gate.GrantKey();
Console.WriteLine(gate.TryOpen(false, 2f, 3f));
Console.WriteLine(gate.TryOpen(true, 3f, 3f));
Console.WriteLine(gate.TryOpen(true, 1f, 3f));
Console.WriteLine(gate.HasKey);
public sealed class GateState
{
public bool HasKey { get; private set; }
public bool Opened { get; private set; }
public void GrantKey() { if (!Opened) HasKey = true; }
public bool TryOpen(bool focused, float distance, float maximum)
{
if (maximum <= 0f || float.IsNaN(maximum) || float.IsInfinity(maximum)) throw new ArgumentOutOfRangeException(nameof(maximum));
if (distance < 0f || float.IsNaN(distance) || float.IsInfinity(distance)) throw new ArgumentOutOfRangeException(nameof(distance));
if (Opened || !HasKey || !focused || distance > maximum) return false;
HasKey = false; Opened = true; return true;
}
}
C# practice output
False False True False False
Query the nearest blocker, then make one explicit state transition before changing the scene.