Add a surface footstep audio profile
verifiedAgainst e50826a · verifiedOn 2026-09-10.
What you will build
Section titled “What you will build”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.
// Concrete surface profile mapping:// BlockId.Basalt -> FootstepSurface.Stone (or dedicated FootstepSurface.Basalt)// Resources/Audio/Footsteps/FootstepBasalt2.wav// Resources/Audio/Footsteps/FootstepBasalt3.wavWhere this sits
Section titled “Where this sits”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-onBlockIdinto an acousticFootstepSurface. - Surface Clip Bank (
Player) dynamically picks non-repeating one-shots fromAssets/_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.
Before you start
Section titled “Before you start”Read Docs/MASTERPLAN.md §14.1 for audio surface contracts and review:
FootstepAudioRuntime, stride interval integration and world sampling.FootstepSurfaceTable, block-to-surface family resolution.SurfaceClipBank, resource loading and anti-repeat history.FootstepSurfaceTableTests, test suite validating mapping rules.
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. |
The build, step by step
Section titled “The build, step by step”1. Extend the FootstepSurface enum
Section titled “1. Extend the FootstepSurface enum”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.
2. Map blocks to the surface family
Section titled “2. Map blocks to the surface family”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.wavAssets/_Game/Resources/Audio/Footsteps/FootstepMetal2.wavAssets/_Game/Resources/Audio/Footsteps/FootstepMetal3.wavThe 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; }}4. Floating-origin distance integration
Section titled “4. Floating-origin distance integration”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.
5. World underfoot block resolution
Section titled “5. World underfoot block resolution”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 fallbackNotice 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.
6. Write focused EditMode tests
Section titled “6. Write focused EditMode tests”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));}Verify
Section titled “Verify”1. Execute Unit Tests Headlessly
Section titled “1. Execute Unit Tests Headlessly”Run the focused player audio tests via Unity’s batchmode runner:
/opt/unity/Editor/Unity -batchmode -nographics \ -projectPath /home/soulwax/workspace/engines/unity/minecraft/Minecraft-HD \ -runTests -testPlatform EditMode \ -testFilter VoxelSandbox.Tests.FootstepSurfaceTableTestsVerify all assertions pass, confirming that every registered surface family maps faithfully.
2. Audition in Play Mode
Section titled “2. Audition in Play Mode”- Enter Play Mode in
Gameplay.unity. - Walk on grass: verify soft, muffled footsteps.
- Walk onto cobblestone or smooth stone: verify sharp, hard stone footsteps.
- Walk onto the newly authored surface (e.g. Iron Ore): verify the distinct audio clip bank plays with natural pitch variance.
- Stand still and rotate the camera: verify no footsteps fire.
- Jump and fall: verify no footsteps play while airborne until touching the ground.
Now do your own
Section titled “Now do your own”To author a brand-new surface profile (e.g. Glass, Gravel, or Crystal):
- Record or acquire audio one-shots: Gather 3–4 clean, short ($< 0.3\text{ s}$) footstep samples. Trim silence and normalize amplitude.
- Import into Resources: Save them as 16-bit 44.1 kHz WAV files into
Assets/_Game/Resources/Audio/Footsteps/with namesFootstep<Name>.wav,Footstep<Name>2.wav, etc. - Declare enum value: Add
<Name>toFootstepSurfaceinFootstepSurfaceTable.cs. - Map palette entries: In
FootstepSurfaceTable.For(...), add the block IDs fromGenerationBlockPalettethat should emit this sound. - Add EditMode assertions: Verify the new mappings in
FootstepSurfaceTableTests.cs. - Play Mode check: Walk across the block in the game and verify the no-repeat selection and pitch modulation.
Pitfalls
Section titled “Pitfalls”| 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.