Skip to content
Edit on GitHub

Add a surface footstep audio profile

verifiedAgainst e50826a · verifiedOn 2026-09-10.

In Voxamine, moving across different geological surfaces emits distinct acoustic feedback: footsteps on grass sound muffled and leafy, gravel crunching on dirt differs from the sharp strike of smooth stone, and trudging through snow yields a soft compression.

You will author a custom surface footstep profile (e.g. Metal or Basalt / Vitric Lava) by extending the FootstepSurface enum, mapping block palette definitions in FootstepSurfaceTable, loading audio clips via SurfaceClipBank, and maintaining strict floating-origin rebase safety inside FootstepAudioRuntime.

Resources/Audio/Footsteps/FootstepBasalt.wav
// Concrete surface profile mapping:
// BlockId.Basalt -> FootstepSurface.Stone (or dedicated FootstepSurface.Basalt)
// Resources/Audio/Footsteps/FootstepBasalt2.wav
// Resources/Audio/Footsteps/FootstepBasalt3.wav

The footstep audio pipeline bridges player kinematics, voxel streaming world queries, clip variant banking, and the Unity audio mixer:

  • Player Kinematics (CharacterController) provides grounded status and planar velocity.
  • Footstep Audio Runtime (Player) accumulates distance traversed and samples underfoot block coordinates.
  • Surface Table (Player $\to$ Data.Blocks) categorizes the walked-on BlockId into an acoustic FootstepSurface.
  • Surface Clip Bank (Player) dynamically picks non-repeating one-shots from Assets/_Game/Resources/Audio/Footsteps/.
  • Audio Mixer (GameplayAudioMixer) routes the output to the master gameplay audio bus with randomized pitch variance.
flowchart TD
  subgraph KINEMATICS["Player Physics"]
    CC["CharacterController\nisGrounded + velocity"]
  end

  subgraph RUNTIME["FootstepAudioRuntime"]
    VEL_ACC["Planar Distance Accumulator\n(dx = speed * dt)"]
    STRIDE_GATE["Stride Gate\n(accumulated >= 2.2m)"]
    FOOT_POS["Foot Cell Query\n(bounds.min.y - 0.1m + OriginOffset)"]
  end

  subgraph WORLD["ChunkStreamingRuntime"]
    GET_BLOCK["GetBlockOrAir(footCell)\nauthoritative BlockId"]
  end

  subgraph LOOKUP["FootstepSurfaceTable"]
    PALETTE["GenerationBlockPalette\nnamed BlockId matching"]
    SURF_ENUM["FootstepSurface\n(Grass, Stone, Wood, Sand, Snow, Dirt)"]
  end

  subgraph BANK["SurfaceClipBank"]
    LOAD["Resources.Load\n(Audio/Footsteps/Footstep{Surface}N)"]
    NO_REPEAT["NoRepeatRandom\n(avoids immediate duplicate clip)"]
  end

  subgraph MIXER["Unity Audio Engine"]
    PITCH["Pitch Variance\n(1.0 ± 0.08)"]
    BUS["GameplayAudioMixer\nGameplayGroup output"]
  end

  CC --> VEL_ACC
  VEL_ACC --> STRIDE_GATE
  STRIDE_GATE --> FOOT_POS
  FOOT_POS --> GET_BLOCK
  GET_BLOCK --> PALETTE
  PALETTE --> SURF_ENUM
  SURF_ENUM --> LOAD
  LOAD --> NO_REPEAT
  NO_REPEAT --> PITCH
  PITCH --> BUS

The critical invariant is never diffing Transform.position directly. When the world shifts during a floating-origin rebase, Transform.position jumps by hundreds of meters in a single frame. Reading CharacterController.velocity ensures only genuine player displacement accumulates stride distance.

Read Docs/MASTERPLAN.md §14.1 for audio surface contracts and review:

Review these architectural constraints:

Constraint Value / Pattern Rationale
Stride step distance 2.2 m Natural stride cadence matching first-person view bobbing.
Speed threshold minimumPlanarSpeed = 0.1 m/s Prevents sub-pixel jitter or micro-sliding from triggering steps.
Pitch variation Random.Range(-0.08f, 0.08f) Prevents the “machine-gun” acoustic repetition fatigue.
Bank variant limit Up to 8 clips per surface (MaxVariants = 8) Scanned sequentially starting at Footstep<Surface>.wav, then ...2, ...3 until the first missing file.
Anti-repeat logic NoRepeatRandom.NextIndex Ensures variant $N$ is never played two strides in a row if multiple variants exist.
Fallback behavior FootstepSurface.Default $\to$ footstepClip Air, water, or unrecognized blocks safely fall back to the generic step clip without throwing.

Open FootstepSurfaceTable.cs and append your new surface family:

public enum FootstepSurface
{
Default = 0,
Grass,
Stone,
Wood,
Sand,
Snow,
Dirt,
Metal // New surface family
}

Default must remain 0. When a block has no dedicated profile, the runtime resolves to Default and plays the serialized fallback clip.

Update FootstepSurfaceTable.For. Always map against GenerationBlockPalette properties rather than hardcoding raw numeric IDs, ensuring renumbered catalogues remain stable:

public static FootstepSurface For(BlockId block, GenerationBlockPalette palette)
{
if (palette == null || block.IsAir)
{
return FootstepSurface.Default;
}
ushort id = block.Value;
// Existing surface families...
if (id == palette.Grass.Value || id == palette.Leaves.Value)
{
return FootstepSurface.Grass;
}
// Map metal blocks (Iron Ore, or future metallic apparatus blocks)
if (id == palette.IronOre.Value)
{
return FootstepSurface.Metal;
}
// Default fallback for torches, water, and unknown blocks
return FootstepSurface.Default;
}

3. Author and ledger audio assets in Resources

Section titled “3. Author and ledger audio assets in Resources”

SurfaceClipBank loads clips from Assets/_Game/Resources/Audio/Footsteps/ using an exact naming convention:

  • First clip: Footstep<Surface>.wav
  • Subsequent variants: Footstep<Surface>2.wav, Footstep<Surface>3.wav, etc.

For example, for Metal:

Assets/_Game/Resources/Audio/Footsteps/FootstepMetal.wav
Assets/_Game/Resources/Audio/Footsteps/FootstepMetal2.wav
Assets/_Game/Resources/Audio/Footsteps/FootstepMetal3.wav

The loader scans variants $1$ through $8$. If FootstepMetal2.wav is missing, loading stops; consecutive numbering without gaps is strictly required:

for (int variant = 1; variant <= MaxVariants; variant++)
{
string name = resourcePrefix + surface + (variant == 1 ? string.Empty : variant.ToString());
var clip = Resources.Load<AudioClip>(name);
if (clip != null)
{
loaded.Add(clip);
}
else if (variant > 1)
{
break;
}
}

Inspect FootstepAudioRuntime.Update to understand why distance accumulation relies on planar velocity:

Vector3 velocity = characterController.velocity;
float planarSpeedSquared = velocity.x * velocity.x + velocity.z * velocity.z;
float minimumPlanarSpeedSquared = minimumPlanarSpeed * minimumPlanarSpeed;
if (!characterController.isGrounded || planarSpeedSquared < minimumPlanarSpeedSquared)
{
distanceAccumulated = 0f;
return;
}
float planarSpeed = Mathf.Sqrt(planarSpeedSquared);
distanceAccumulated += planarSpeed * Time.deltaTime;
if (distanceAccumulated < stepDistance)
{
return;
}
distanceAccumulated = 0f;
PlayStep();

If a floating-origin rebase occurs, the player’s Transform.position shifts by dozens or hundreds of blocks within one frame. Because CharacterController.velocity represents physics locomotion rather than coordinate rebases, it ignores origin shifts, preventing step sound bursts.

When distanceAccumulated >= stepDistance, FootstepAudioRuntime.ResolveClip queries the world grid directly below the player’s bounding box:

Bounds bounds = characterController.bounds;
var origin = world.OriginBlockOffset;
var footCell = new WorldBlockPosition(
Mathf.FloorToInt(bounds.center.x) + origin.X,
Mathf.FloorToInt(bounds.min.y - 0.1f) + origin.Y,
Mathf.FloorToInt(bounds.center.z) + origin.Z);
BlockId underfoot = world.GetBlockOrAir(footCell);
FootstepSurface surface = FootstepSurfaceTable.For(underfoot, world.GenerationSettings?.Blocks);
if (bank.TryPick(surface, out AudioClip surfaceClip))
{
return surfaceClip;
}
return footstepClip; // Serialized generic fallback

Notice the addition of world.OriginBlockOffset. In a chunk-streamed floating-origin coordinate system, scene space must be converted to absolute world coordinates before querying ChunkStreamingRuntime.

Add unit tests in FootstepSurfaceTableTests.cs to verify that the block mappings work as intended and that invalid inputs fall back gracefully:

[Test]
public void MetalBlocksResolveToMetalFamily()
{
Assert.That(FootstepSurfaceTable.For(Palette.IronOre, Palette), Is.EqualTo(FootstepSurface.Metal));
}
[Test]
public void UnknownIdFallsThroughToDefault()
{
Assert.That(FootstepSurfaceTable.For(new BlockId(60000), Palette), Is.EqualTo(FootstepSurface.Default));
}

Run the focused player audio tests via Unity’s batchmode runner:

Terminal window
/opt/unity/Editor/Unity -batchmode -nographics \
-projectPath /home/soulwax/workspace/engines/unity/minecraft/Minecraft-HD \
-runTests -testPlatform EditMode \
-testFilter VoxelSandbox.Tests.FootstepSurfaceTableTests

Verify all assertions pass, confirming that every registered surface family maps faithfully.

  1. Enter Play Mode in Gameplay.unity.
  2. Walk on grass: verify soft, muffled footsteps.
  3. Walk onto cobblestone or smooth stone: verify sharp, hard stone footsteps.
  4. Walk onto the newly authored surface (e.g. Iron Ore): verify the distinct audio clip bank plays with natural pitch variance.
  5. Stand still and rotate the camera: verify no footsteps fire.
  6. Jump and fall: verify no footsteps play while airborne until touching the ground.

To author a brand-new surface profile (e.g. Glass, Gravel, or Crystal):

  1. Record or acquire audio one-shots: Gather 3–4 clean, short ($< 0.3\text{ s}$) footstep samples. Trim silence and normalize amplitude.
  2. Import into Resources: Save them as 16-bit 44.1 kHz WAV files into Assets/_Game/Resources/Audio/Footsteps/ with names Footstep<Name>.wav, Footstep<Name>2.wav, etc.
  3. Declare enum value: Add <Name> to FootstepSurface in FootstepSurfaceTable.cs.
  4. Map palette entries: In FootstepSurfaceTable.For(...), add the block IDs from GenerationBlockPalette that should emit this sound.
  5. Add EditMode assertions: Verify the new mappings in FootstepSurfaceTableTests.cs.
  6. Play Mode check: Walk across the block in the game and verify the no-repeat selection and pitch modulation.
Symptom Cause Fix
Rapid machine-gun footsteps fire when crossing chunk boundaries. The runtime diffed Transform.position instead of accumulating CharacterController.velocity. Always integrate characterController.velocity.magnitude * Time.deltaTime. Floating-origin rebasing moves the transform instantly.
Newly added footstep clips never play; game falls back to generic sound. Resource filename does not start with Footstep<Surface> or has a gap in variant numbering (e.g., 1 and 3 without 2). Follow the strict naming pattern: FootstepStone.wav, FootstepStone2.wav. The loader breaks at the first missing variant.
Steps play while swimming in water or falling from heights. CharacterController.isGrounded check was omitted or bypassed. Only accumulate distance when characterController.isGrounded is true and planar speed exceeds minimumPlanarSpeed.
Block mapping breaks after reordering the block catalogue. Hardcoded numeric literals (e.g. block.Value == 14) were used instead of palette properties. Always compare against palette.<Block>.Value (palette.IronOre.Value).
Audio clips clip or distort loudly when walking briskly. AudioSource output was sent directly to hardware instead of GameplayAudioMixer.GameplayGroup. Assign source.outputAudioMixerGroup = GameplayAudioMixer.GameplayGroup in Awake().
Steps sound monotonous and artificial over long journeys. Pitch variance was set to $0$ or anti-repeat banking was bypassed. Use pitchVariance = 0.08f and verify NoRepeatRandom.NextIndex picks different clips sequentially.

User-contributed notes

Corrections, clarifications, and practical tips for this page. Anonymous is fine — a name is optional. Basic Markdown works: **bold**, *italic*, `code`, and links.

Notes policy

Notes are lightly filtered for spam and may be edited or removed. Keep them about this page — no support requests, no personal data, nothing you would not publish. Links are limited and marked nofollow.

  1. Loading notes…