The current project has a marching-cubes voxel terrain you can dig into, and the thing that turned into a full rewrite was, of all features, footstep sounds. Twice.
This post is about where I finally landed: a chain of small resolvers behind one IFootstepSurfaceResolver interface, with a separate resolver for the dug-out interior of a voxel chunk.
One question, multiple sources of truth
“What footstep sound should play right now?” sounds like one question. In practice it isn’t.
A player can be standing on a tagged collider with an obvious material like concrete, metal grating, or wood. They can also be on a mesh whose collider is shared across other meshes, so the collider alone can’t tell you the material. They can be on a voxel chunk where the surface comes from the voxel data, not a tag. And they can be inside a hollow they just dug — the collider underfoot is still the chunk, but the surface they’re touching is the carved interior, which is a different material than the outer shell.
The first two cases get answered fine by a provider component on the collider. The other two don’t, and the carved-interior case is the one that broke my first attempt.
What I tried first
The first version was a switch on collider.tag inside FootstepDetector. It held up while every walkable thing had a unique tag and fell over the moment voxel chunks showed up. A chunk is one collider. Its surface type varies per vertex. And once the player digs a hole, the inside of that hole has nothing identifying it — the collider is still the same chunk MeshCollider, just with a different shape.
I tried a couple of patches before giving up on the switch. Tagging the chunk with its dominant material lied as soon as the player walked onto a minority material. Spawning marker colliders for dug regions felt like abusing physics for an audio question. Both attempts kept pushing knowledge of voxels into the footstep code, which shouldn’t have known voxels exist.
What I wanted was a chain of small things that each answer “do you know what this is?” with yes or no, and a way to add the voxel-specific answers without touching the generic path.
The interface
The interface is tiny:
public interface IFootstepSurfaceResolver
{
bool TryResolve(RaycastHit hit, out SurfaceTypeSO surface);
}
SurfaceTypeSO is a ScriptableObject holding the audio cues for step, land, and takeoff, plus pitch, volume, and a step-distance multiplier. The detector raycasts downward and hands the closest hit to the resolver. The resolver either fills in a surface and returns true, or returns false so the next resolver gets a turn.
The chain
CompositeFootstepSurfaceResolver is an array walk:
public sealed class CompositeFootstepSurfaceResolver : IFootstepSurfaceResolver
{
private readonly IFootstepSurfaceResolver[] resolvers;
public CompositeFootstepSurfaceResolver(IFootstepSurfaceResolver[] resolvers)
{
this.resolvers = resolvers;
}
public bool TryResolve(RaycastHit hit, out SurfaceTypeSO surface)
{
foreach (var resolver in resolvers)
{
if (resolver.TryResolve(hit, out surface))
return true;
}
surface = null;
return false;
}
}
The detector wires it like this in Awake:
resolver = new CompositeFootstepSurfaceResolver(new IFootstepSurfaceResolver[]
{
digCarvedResolver,
new ColliderRaycastSurfaceResolver(config),
new DefaultSurfaceResolver(config),
});
Three resolvers run in array order for each hit. DigCarvedSurfaceResolver goes first: if the hit point falls inside a recent dig brush footprint, it returns the freshly dug surface. Failing that, ColliderRaycastSurfaceResolver looks for an IFootstepSurfaceProvider component on the hit collider and asks it. Last is DefaultSurfaceResolver, which always returns the configured default so the player never silently fails to make a sound.
The ordering mattered for my case. A carved voxel hit is also a collider hit, so the carved resolver had to win first or the chunk’s outer surface answered instead.
Why the dig case needs its own resolver
This is the part I kept getting wrong. The carved interior isn’t a separate object. It’s the same MeshCollider as the rest of the chunk, just remeshed after the brush ran. There’s nothing to attach a SurfaceIdentifierMB to that wouldn’t lie about the rest of the chunk.
So the dig resolver doesn’t look at colliders. It listens for dig events and remembers where the brush hit:
public DigCarvedSurfaceResolver(
FootstepConfigSO config,
SurfaceTypeSO freshlyDugSurface,
DigCarvedEventChannelSO channel,
Func<float> getTime = null)
{
this.config = config;
this.freshlyDugSurface = freshlyDugSurface;
this.getTime = getTime ?? (() => Time.time);
stamps = new DigStamp[config.DigCarvedStampCapacity];
Subscribe(channel);
}
Each DigCarved event carries the brush center, its radius, and a HasChanged flag. The flag covers the case where the brush ran but density didn’t actually change (for example, digging where there was already nothing). The resolver only stores stamps where HasChanged is true, in a fixed-size ring buffer:
private void HandleDigCarved(DigCarved carved)
{
if (!carved.HasChanged)
return;
stamps[head] = new DigStamp
{
Center = carved.BrushCenter,
Radius = carved.Radius,
Timestamp = getTime(),
};
head = (head + 1) % stamps.Length;
if (count < stamps.Length)
count++;
}
TryResolve walks the buffer and checks distance from the hit point to each stamp center against that stamp’s radius, skipping anything older than the configured max age:
public bool TryResolve(RaycastHit hit, out SurfaceTypeSO surface)
{
float cutoff = getTime() - config.DigCarvedStampMaxAge;
for (int i = 0; i < count; i++)
{
ref DigStamp stamp = ref stamps[i];
if (stamp.Timestamp < cutoff)
continue;
if (Vector3.Distance(hit.point, stamp.Center) <= stamp.Radius)
{
surface = freshlyDugSurface;
return true;
}
}
surface = null;
return false;
}
The ring buffer caps memory. Capacity comes from FootstepConfigSO and defaults to 32. The max-age cutoff defaults to 8 seconds, which means the freshly-dug surface fades back to the chunk’s normal material if the player doesn’t walk over the hole soon. That cutoff isn’t about physical accuracy. It’s a design call that “freshly dug” should mean “I just dug this,” not “I dug here yesterday.”
The Func<float> getTime parameter exists so tests can inject a simulated clock without going through Time.time. It defaults to () => Time.time so production callers don’t have to think about it. That single seam is the reason the dig tests are clean — more on that below.
The collider side
For everything that isn’t carved voxel, the second resolver does the obvious thing:
public bool TryResolve(RaycastHit hit, out SurfaceTypeSO surface)
{
if (hit.collider == null)
{
surface = null;
return false;
}
var provider = hit.collider.GetComponent<IFootstepSurfaceProvider>();
if (provider == null)
{
surface = null;
return false;
}
surface = provider.GetSurface();
return true;
}
IFootstepSurfaceProvider is the contract on the world side:
public interface IFootstepSurfaceProvider
{
SurfaceTypeSO GetSurface();
}
The default implementation is a one-field MonoBehaviour:
public sealed class SurfaceIdentifierMB : MonoBehaviour, IFootstepSurfaceProvider
{
[SerializeField] private SurfaceTypeSO surface;
public SurfaceTypeSO GetSurface() => surface;
}
That covers most cases. A prefab gets one of these, an artist drops in a SurfaceTypeSO, and the resolver picks it up. The detector doesn’t have to know whether the surface came from a tag, a script, a procedural lookup, or a voxel chunk. It just needs some IFootstepSurfaceProvider to have answered.
The accumulator
The piece I should have written first, but didn’t, is StepDistanceAccumulator. Plain C# class, no Unity dependencies:
public sealed class StepDistanceAccumulator
{
private float accumulated;
public float Accumulated => accumulated;
public void Accumulate(float horizontalDelta)
{
accumulated += Mathf.Max(0f, horizontalDelta);
}
public bool TryConsume(float threshold)
{
if (accumulated < threshold)
return false;
accumulated -= threshold;
return true;
}
public void Reset() => accumulated = 0f;
}
Two details I had to figure out the hard way. Negative deltas are clamped to zero, so walking backwards doesn’t cancel out walking forwards. And TryConsume subtracts the threshold instead of zeroing the counter, so any surplus carries into the next step. Without the carry, footstep cadence drifted on me during framerate dips — I had that bug for a week before I realized.
It tests in three lines. That alone was worth the extraction.
The detector
FootstepDetector is the MonoBehaviour that holds the accumulator and the resolver together. The player controller calls Tick(horizontalDelta, isGrounded, verticalVelocity, isSprinting) each frame. The detector fires three kinds of events:
Landfires when the player wasn’t grounded last frame but is now, and bothairTime >= MinAirTimeand-verticalVelocityLastFrame >= MinLandingSpeedhold. The two thresholds together filter out the tiny rebounds you get from physics jitter.Stepfires when the accumulator consumes the threshold while grounded. The threshold is(walk or sprint step distance) * surface.StepDistanceMultiplier, so sand or snow can slow the cadence without the controller knowing.Takeofffires from a separateonJumpPerformedevent channel, because takeoff is a discrete intent andTickonly sees sampled state.
Each event reaches the resolver via a Physics.RaycastAll straight down from the detector’s transform, sorted by distance, with the nearest hit going to resolver.TryResolve. The result becomes a FootstepEvent:
public readonly struct FootstepEvent
{
public SurfaceTypeSO SurfaceType { get; }
public Vector3 WorldPosition { get; }
public bool IsSprinting { get; }
public FootstepEventKind Kind { get; }
// ...
}
The detector raises that struct on a FootstepEventChannelSO (a Reactive SO event channel) and stops thinking about it. No audio code runs in here.
The audio router
Audio is its own MonoBehaviour. It subscribes to the footstep channel and emits an AudioPlayRequest:
private void HandleFootstep(FootstepEvent footstepEvent)
{
AudioCueSO cue = footstepEvent.SurfaceType?.GetCueForKind(footstepEvent.Kind);
if (cue == null) return;
var surface = footstepEvent.SurfaceType;
float pitchMultiplier = (surface != null && surface.PitchMultiplier != 1f) ? surface.PitchMultiplier : 1f;
float volumeMultiplier = (surface != null && surface.VolumeMultiplier != 1f) ? surface.VolumeMultiplier : 1f;
var request = new AudioPlayRequest(cue, footstepAudioConfig, footstepEvent.WorldPosition,
pitchMultiplier: pitchMultiplier, volumeMultiplier: volumeMultiplier);
onAudioPlayRequested?.RaiseEvent(request);
}
AudioPlayRequest is the request type for the audio system in the same project. The footstep code doesn’t know whether anything picks the request up, doesn’t know about AudioSource, doesn’t know about pooling. It posts a request and exits. The per-surface pitch and volume multipliers ride along on the request so the player layer doesn’t have to coordinate with the audio layer for surface-specific flavor.
The dig action has its own router, DigCarvedAudioRouter, which listens for DigCarved events directly and emits a dig sound. It throttles to once every minIntervalSeconds (0.25 by default) because dig events fire on a roughly 0.05s interval and the audio would otherwise buzz. Keeping the footstep router and the dig router independent let me iterate the dig sound without breaking footstep timing.
What testing looks like
Two of the three resolvers and the accumulator have Edit Mode tests. DefaultSurfaceResolver is too trivial to bother. The shapes that made the rest easy:
DigCarvedSurfaceResolvertakes aFunc<float>clock instead of readingTime.timedirectly. Tests pass() => simulatedTimeand bump the variable to simulate stamp expiry.StepDistanceAccumulatoris plain C#, so its tests don’t touchScriptableObjectat all.- The composite is tested with hand-rolled
AlwaysResolveWith,NeverResolve, andCallCountingResolverdoubles. One of those tests asserts that the second resolver never runs when the first one returns true, which is exactly the ordering guarantee the dig case depends on.
The dig resolver tests cover the cases I cared about: empty buffer returns false, an in-range stamp returns the fresh surface, an out-of-range stamp doesn’t, an expired stamp doesn’t, multiple stamps find the right one, and a HasChanged = false event doesn’t add a stamp. Six tests, and they caught two real regressions while I was tightening the loop.
What the shape buys
The thing I keep coming back to is this: when the next surface type shows up (water, ice, a magical fungal patch that slows you), adding it is a new resolver class plus one line in the array. Nothing about the collider path, the audio router, or the detector has to move.
The other direction works too. When I want the freshly-dug sound to behave differently — longer fade, different brush-radius math, a per-biome carved surface — all of that lives inside DigCarvedSurfaceResolver and FootstepConfigSO. Nothing else has to learn that voxels exist.
Wrap-up
A switch on collider tags worked for me right up until the first feature that didn’t fit the assumption I’d baked in. A voxel chunk with a dug interior was that feature for this project.
What took me the longest was accepting that, in my setup at least, surface identification had more than one source of truth. Once I stopped fighting that, the chain itself was mechanical to write.
Unity Asset StoreReactive SO | Game ToolkitsScriptableObject-based reactive architecture for Unity. Variables, Event Channels, Runtime Sets, GPU Sync, Reactive Entity Sets, and dedicated debugging windows.