# 3. Interactable Components with Audio
Part of [[Interactable Audio]]. Previous: [[2. Pooled AudioSource Service]]. Next: [[4. Interactable Audio Pitfalls and Quest]].
> [!note] Verification status
> Uses `Collision.relativeVelocity`, `Collision.GetContact`, `Collision2D.relativeVelocity`, `Physics.OverlapSphereNonAlloc`, `AudioSource` members - present in the 6000.5 reference (checked 2026-11-07) - plus the `AudioService` / `InteractionSound` types from Parts 1-2. **Code not compiled or play-tested.**
## 3.1 Interaction plumbing
```csharp
using UnityEngine;
public interface IInteractable
{
string Prompt { get; }
bool CanInteract(GameObject user);
void Interact(GameObject user);
}
```
```csharp
using UnityEngine;
using UnityEngine.InputSystem;
/// Finds the nearest IInteractable in range and calls it on the Interact action.
public class Interactor : MonoBehaviour
{
[SerializeField] InputActionReference interact;
[SerializeField] float radius = 1.6f;
[SerializeField] LayerMask interactableMask;
[SerializeField] InteractionSound focusSound; // soft tick when a new target is focused
readonly Collider[] _hits = new Collider[16];
IInteractable _focused;
public IInteractable Focused => _focused;
void OnEnable() { interact.action.Enable(); interact.action.performed += OnInteract; }
void OnDisable() { interact.action.performed -= OnInteract; }
void Update()
{
int n = Physics.OverlapSphereNonAlloc(transform.position, radius, _hits, interactableMask, QueryTriggerInteraction.Collide);
IInteractable best = null; float bestD = float.MaxValue;
for (int i = 0; i < n; i++)
{
var it = _hits[i].GetComponentInParent<IInteractable>();
if (it == null || !it.CanInteract(gameObject)) continue;
float d = (_hits[i].transform.position - transform.position).sqrMagnitude;
if (d < bestD) { bestD = d; best = it; }
}
if (best != _focused)
{
_focused = best;
if (_focused != null && focusSound) AudioService.Play(focusSound, transform.position);
}
}
void OnInteract(InputAction.CallbackContext _) => _focused?.Interact(gameObject);
}
```
> [!warning] Code not compiled in Unity
> Not compiled. `GetComponentInParent<IInteractable>()` works with interfaces in current Unity versions; if yours complains, look up a concrete base class instead. Show `Focused.Prompt` with UI Toolkit ([[UI Toolkit and Diegetic UI]]).
## 3.2 Door: open, close, locked, motor loop
```csharp
using UnityEngine;
public class AudioDoor : MonoBehaviour, IInteractable
{
[SerializeField] Transform hinge;
[SerializeField] float openAngle = 100f;
[SerializeField] float openTime = 0.8f;
[SerializeField] bool locked;
[SerializeField] string requiredKeyId = "";
[Header("Sounds")]
[SerializeField] InteractionSound openSound, closeSound, lockedSound, unlockSound, motorLoop, slamSound;
bool _open;
float _t; // 0 closed .. 1 open
float _dir; // +1 opening, -1 closing, 0 idle
AudioHandle _motor;
public string Prompt => locked ? "Locked" : (_open ? "Close" : "Open");
public bool CanInteract(GameObject user) => true;
public void Interact(GameObject user)
{
if (locked)
{
var keys = user.GetComponent<IKeyRing>();
if (keys != null && keys.Has(requiredKeyId))
{
locked = false;
AudioService.Play(unlockSound, hinge.position);
}
else
{
// Level Up! "show 'em the door, then send 'em after the key": the clunk says LOCKED.
AudioService.Play(lockedSound, hinge.position);
return;
}
}
_open = !_open;
_dir = _open ? 1f : -1f;
AudioService.Play(_open ? openSound : closeSound, hinge.position);
if (motorLoop && !_motor.IsPlaying) _motor = AudioService.Play(motorLoop, hinge.position, hinge);
}
void Update()
{
if (_dir == 0f) return;
_t = Mathf.Clamp01(_t + _dir * Time.deltaTime / openTime);
hinge.localRotation = Quaternion.Euler(0f, Mathf.SmoothStep(0f, openAngle, _t), 0f);
if ((_dir > 0f && _t >= 1f) || (_dir < 0f && _t <= 0f))
{
if (_dir < 0f && slamSound) AudioService.Play(slamSound, hinge.position);
_dir = 0f;
_motor.Stop(0.15f);
}
}
}
public interface IKeyRing { bool Has(string keyId); }
```
> [!warning] Code not compiled in Unity
> Not compiled. The motor loop's `InteractionSound` must have **Loop** ticked; the handle's fade-out stop keeps the loop from clicking off. For animation-driven doors use [[Timeline Basics]] signals or animation events to fire the same sounds.
## 3.3 Lever / switch with a hazard telegraph
```csharp
using UnityEngine;
using UnityEngine.Events;
public class AudioLever : MonoBehaviour, IInteractable
{
[SerializeField] InteractionSound pullSound, resetSound, deniedSound;
[SerializeField] float cooldown = 1f;
[SerializeField] UnityEvent onPulled; // e.g. start a RhythmicEffectorHazard, open a gate
bool _on;
float _readyAt;
public string Prompt => _on ? "Reset" : "Pull";
public bool CanInteract(GameObject user) => true;
public void Interact(GameObject user)
{
if (Time.time < _readyAt) { AudioService.Play(deniedSound, transform.position); return; }
_readyAt = Time.time + cooldown;
_on = !_on;
AudioService.Play(_on ? pullSound : resetSound, transform.position);
if (_on) onPulled.Invoke();
}
}
```
> [!warning] Code not compiled in Unity
> Not compiled or play-tested.
Wire `onPulled` to a hazard's warning (e.g. the beat hazard in [[4. Physics 2D Effectors and Joints]]) - the lever click + wind-up is the **telegraph** Rogers insists on (Level 12).
## 3.4 Pickup with a streak pitch
```csharp
using UnityEngine;
/// Coins/gems: pitch climbs one semitone per pickup within the streak window (greed made audible).
public class AudioPickup : MonoBehaviour
{
[SerializeField] InteractionSound pickupSound;
[SerializeField] int value = 1;
[SerializeField] string playerTag = "Player";
static int s_streak;
static float s_lastPickup = -999f;
const float StreakWindow = 0.6f;
const int MaxSemitones = 12;
void OnTriggerEnter(Collider other)
{
if (!other.CompareTag(playerTag)) return;
s_streak = Time.time - s_lastPickup <= StreakWindow ? s_streak + 1 : 0;
s_lastPickup = Time.time;
float pitchMul = Mathf.Pow(2f, Mathf.Min(s_streak, MaxSemitones) / 12f);
AudioService.Play(pickupSound, transform.position, null, 1f, pitchMul);
// TODO: add 'value' to score - see High Scores, Saves and Player Stats
gameObject.SetActive(false); // or return to a pickup pool
}
}
```
> [!warning] Code not compiled in Unity
> Not compiled. Set the pickup sound's **cooldown** to ~0 and **maxInstances** to ~6, otherwise a fast streak gets swallowed by the anti-spam. Score hook: [[High Scores, Saves and Player Stats]]. Level 13: "never underestimate the greed of the player" - the rising pitch rewards chaining.
## 3.5 Physics impact audio (3D)
```csharp
using UnityEngine;
/// Impact sounds scaled by relative speed, with light/heavy variants and per-object cooldown.
[RequireComponent(typeof(Rigidbody))]
public class ImpactAudio : MonoBehaviour
{
[SerializeField] InteractionSound lightImpact, heavyImpact;
[SerializeField] float minSpeed = 0.6f; // below: silent
[SerializeField] float heavySpeed = 4f; // above: heavy variant
[SerializeField] float maxSpeed = 10f; // volume saturates here
[SerializeField] float objectCooldown = 0.08f;
[SerializeField] float noiseRadiusPerMps = 1.5f; // for enemy hearing
float _nextAllowed;
void OnCollisionEnter(Collision c)
{
if (Time.time < _nextAllowed) return;
// Use the component of relative velocity along the contact normal (glancing scrapes are quieter).
ContactPoint cp = c.GetContact(0);
float speed = Mathf.Abs(Vector3.Dot(c.relativeVelocity, cp.normal));
if (speed < minSpeed) return;
_nextAllowed = Time.time + objectCooldown;
float vol = Mathf.InverseLerp(minSpeed, maxSpeed, speed);
var snd = speed >= heavySpeed && heavyImpact ? heavyImpact : lightImpact;
AudioService.Play(snd, cp.point, null, Mathf.Lerp(0.25f, 1f, vol), Mathf.Lerp(1.05f, 0.95f, vol));
NoiseEvents.Emit(cp.point, speed * noiseRadiusPerMps);
}
}
/// Minimal global noise channel. Bridge this to the hearing sense in your enemy AI.
public static class NoiseEvents
{
public static event System.Action<Vector3, float> Noise; // position, audible radius (m)
public static void Emit(Vector3 pos, float radius) => Noise?.Invoke(pos, radius);
}
```
> [!warning] Code not compiled in Unity
> Not compiled. Both bodies with `ImpactAudio` will fire for the same collision - put it on the lighter/more "voiced" object only, or let the per-sound `maxInstances` absorb the duplicate. Subscribe your enemy hearing from [[Organic Enemy Sensing]] to `NoiseEvents.Noise`. Add a matching dust puff from [[3.Particles Spawning Particles on Contact]].
**2D version:** identical logic with `OnCollisionEnter2D(Collision2D c)`, `c.relativeVelocity`, `c.GetContact(0).normal` (see [[3. Physics 2D Queries ContactFilter2D and Layers]]). For 2D games set the sounds' `spatialBlend` to 0 or low - stereo pan from position is usually enough.
Apply it to the crate in [[6. Worked Example Pushable Prop]]: drop it from 0.5 m, 2 m, 5 m and listen for three distinct impacts.
## 3.6 Breakable
```csharp
using UnityEngine;
public class AudioBreakable : MonoBehaviour
{
[SerializeField] InteractionSound breakSound;
[SerializeField] GameObject brokenPrefab;
[SerializeField] float breakSpeed = 5f;
void OnCollisionEnter(Collision c)
{
if (c.relativeVelocity.magnitude < breakSpeed) return;
// Play at the object's position, NOT on a source attached to this object (it's about to vanish).
AudioService.Play(breakSound, transform.position);
if (brokenPrefab) Instantiate(brokenPrefab, transform.position, transform.rotation);
Destroy(gameObject);
}
}
```
The comment is the whole lesson: a sound on an AudioSource *attached to* a destroyed object dies with it. Pooled emitters (or `PlayClipAtPoint`) outlive the object.
## 3.7 Proximity hum (pattern A, voice-friendly)
```csharp
using UnityEngine;
/// A generator/console hum on its own AudioSource that only occupies a voice when the listener is near.
[RequireComponent(typeof(AudioSource))]
public class ProximityLoop : MonoBehaviour
{
[SerializeField] Transform listener;
[SerializeField] float hysteresis = 2f;
[SerializeField] float checkInterval = 0.25f;
AudioSource _src;
float _next;
void Awake()
{
_src = GetComponent<AudioSource>();
_src.loop = true;
_src.playOnAwake = false;
if (listener == null && Camera.main) listener = Camera.main.transform;
}
void Update()
{
if (Time.time < _next || listener == null) return;
_next = Time.time + checkInterval;
float d = Vector3.Distance(transform.position, listener.position);
if (!_src.isPlaying && d < _src.maxDistance)
{
_src.time = Random.Range(0f, _src.clip ? _src.clip.length : 0f); // de-phase identical hums
_src.Play();
}
else if (_src.isPlaying && d > _src.maxDistance + hysteresis) _src.Stop();
}
}
```
> [!warning] Code not compiled in Unity
> Not compiled. Use **Linear** or **Custom** rolloff so volume really reaches 0 at `maxDistance` before you stop it (Logarithmic never quite reaches zero, so stopping causes a click).
## Docs
- `Collision.relativeVelocity`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Collision-relativeVelocity.html
- `Collision.GetContact`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Collision.GetContact.html
- `Collision2D.relativeVelocity`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Collision2D-relativeVelocity.html
- `Physics.OverlapSphereNonAlloc`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Physics.OverlapSphereNonAlloc.html
- `AudioSource.time`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/AudioSource-time.html
- `AudioRolloffMode`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/AudioRolloffMode.html