Avatar game feel: gaits matched to the ground, jump, camera collision, city interaction and audio #30

Open
opened 2026-08-10 07:07:28 +00:00 by jeroen · 1 comment
Owner

Walking around the city read as "sluggish and unrealistic". The input path was
not the cause, and neither was the network — this ticket covers the five things
that actually were, plus the world-contact work that fell out of fixing them.

The code is written. Eight commits sit on this branch; the ticket is the
record and the review handle, not a request to start.

Problem

The feet moved at half the speed of the ground, permanently. Input reaches
the display in one frame — a key press sets predicted_velocity and
smooth_online_avatar_display integrates it the same frame. What read as lag
was foot-slide, and it was arithmetic:

  • WALK_SPEED was 8.0 m/s and was the only speed — a world-class sprint
    used for a stroll along a canal.
  • The Run clip is 0.8 s/cycle ≈ 4 m/s of real stride, played at a
    hard-coded set_speed(1.0).
  • Walk (clip 22) was never loaded; the client knew only Idle (4) and Run (16).

Gravity was a constant 12 m/s descent with no acceleration in it, so
stepping off a kerb and falling off a tower began identically.

The motion constants existed in three private copies (sim, client
prediction, --offline path), each carrying a comment asking the next person to
keep them in step. A mismatch between the first two is invisible except as
rubber-banding; between the first and third the same key produces a different
world depending on how you launched.

The camera went through walls, had no reaction to a landing, and gave no
sense of pace.

Nothing streamed reacted to the avatar being near it. Picking was prim-only
and edit-oriented; benches and shops were scenery you walked through. The pois
and addresses layers were decoded on every streamed cell and thrown away — 467
and 6,211 features in the Groningen fixture
(docs/notes/vector-tile-unused-layers.md).

There was no audio at all. bevy_audio was dropped when the feature list
was slimmed. A city you can walk through in silence reads as a viewer, not a
place.

Approach

A shared speed model. crates/core/src/motion.rs holds what the sim, the
prediction and the offline path must agree on — sim_speed_for_wish (:40),
GRAVITY = 22.0 (:69, above Earth's so a jump does not hang like the moon)
and the integrated step_fall (:94). systems::locomotion holds the client's
half: stroll 1.4 / jog 4.2 / sprint 8.0 m/s
(crates/viberfox/src/systems/player/locomotion.rs:31,34,38) and the clip
reference speeds. Gaits cost nothing on the wireapply_intent derives
speed from the wish vector's magnitude, so a gait is a magnitude the client
picks. Speed ramps in m/s² rather than easing a normalized throttle, or a sprint
start feels exactly as light as a walk start.

Clips matched to the ground. Walk is loaded, the gait is chosen by ground
speed with hysteresis, and playback rate is ground_speed / clip_reference_speed
so the stride covers the distance travelled. Changes cross-fade through
AnimationTransitions instead of stop/play.

Protocol 13 (crates/core/src/protocol.rs:56) for the two things that did
not fit through the magnitude throttle. ClientIntent::jump is a request the
sim grants only from the ground, so holding the bit cannot climb — a predicted
jump is reconciled straight into the floor, since SimWorld::step clamps
y >= 0. Emotes replicate as a level held on the wire for a couple of seconds
so a client arriving mid-wave sees it, which is why they carry a sequence number.

The follow boom collides in 2D and only below 15 m
(free_camera.rs:230) — footprints carry no height, so above an eaves line a 2D
hit means "there is a building over there", not "the view is blocked". In fast,
out slow, or every doorway makes the camera lunge. A landing dips the eye scaled
by impact speed; FOV widens toward a sprint. Driver mode is exempt.

The city answers back. crates/geo/src/places.rs extracts the two dropped
layers out of the TileData map_geometry already parses — no extra request,
no extra decode. systems::world_places keys them by cell in the anchored world
frame and drops them with the cell; systems::interact is the proximity prompt
and the E key. Street furniture becomes circle colliders.

Audio synthesised at startup (systems::soundscape) rather than shipped:
.gitattributes tracks audio by extension, so a shipped footstep would be an LFS
object with the documented silent-failure mode. Footsteps are counted off the
same ground speed that sets the clip rate, so they stay in phase by construction.

Acceptance criteria

  • Walk / jog / sprint are distinct speeds; Shift sprints, Alt strolls
  • The clip's playback rate tracks ground speed — no foot-slide at any gait
  • Gait changes cross-fade rather than popping
  • Space jumps from the ground and takes off if held past 0.25 s; the sim
    grants it, so prediction is not reconciled into the floor
  • Falling is an integrated arc, identical in sim, prediction and --offline
  • 16 play emotes, replicated so a client arriving mid-emote sees it
  • The follow camera does not pass through buildings below 15 m
  • Walking near a bench or a shop raises a prompt naming it; E acts on it
  • Street furniture is solid; the playground token is deliberately not
  • House numbers read out (numeric MVT values included — reading only
    Value::String silently dropped most)
  • Footsteps, landings and wind play, all generated at startup
  • --no-default-features --features solo still builds on a host with no ALSA

Verification

Run in this container on 2026-08-10, all green:

cargo check --workspace                                   # clean
cargo test -p viberfox                                    # 284 passed
cargo test -p viberfox_geo --lib                          # 91 passed
cargo test -p viberfox_core                               # 23 passed
cargo check -p viberfox --no-default-features --features solo   # audio gate holds

New system prerequisite: the audio feature (on by default) pulls
bevy_audio → rodio → cpal → alsa-sys, whose build script panics without
libasound2-dev — it is a pkg-config shim with no vendored fallback. Any Linux
builder needs the package, or --no-default-features --features solo. Installed
in the agent container and added to its image definition on 2026-08-10.

Not verifiable here, needs a workstation: this container has no GPU and no
Vulkan ICD, so nothing was seen. The camera boom, the landing dip, the FOV
coupling, the flight/jump pose and the interaction prompt's placement are all
visually unchecked. cargo shots and a real walk around a city are the
missing gate.

Out of scope

  • Strafe and turn clips. The set contains Run_Left/Run_Right/Run_Back;
    the avatar turns to face its wish direction instead, deliberately.
  • A flight clip. Quaternius has nothing airborne, and the UAL library's
    cannot bind (different rig) — flight and jump are poses on the model
    holders, not clips.
  • Animation events for footsteps. The clips carry none and adding them means
    editing 20 glTFs; steps are counted from ground speed instead.
  • Server-side building collision. The sim holds no geometry; collision stays
    client-side prediction against streamed footprints.
  • Terrain-aware collision. Footprints have no height, which is what gates the
    camera test at 15 m and skips avatar collision while airborne.
  • Sound design beyond the three sources. No UI, ambience or vehicle audio.

Branch: feat/30-avatar-game-feel

Walking around the city read as "sluggish and unrealistic". The input path was not the cause, and neither was the network — this ticket covers the five things that actually were, plus the world-contact work that fell out of fixing them. **The code is written.** Eight commits sit on this branch; the ticket is the record and the review handle, not a request to start. ## Problem **The feet moved at half the speed of the ground, permanently.** Input reaches the display in one frame — a key press sets `predicted_velocity` and `smooth_online_avatar_display` integrates it the same frame. What read as lag was foot-slide, and it was arithmetic: - `WALK_SPEED` was **8.0 m/s** and was the *only* speed — a world-class sprint used for a stroll along a canal. - The `Run` clip is **0.8 s/cycle** ≈ 4 m/s of real stride, played at a hard-coded `set_speed(1.0)`. - `Walk` (clip 22) was never loaded; the client knew only Idle (4) and Run (16). **Gravity was a constant 12 m/s descent with no acceleration in it**, so stepping off a kerb and falling off a tower began identically. **The motion constants existed in three private copies** (sim, client prediction, `--offline` path), each carrying a comment asking the next person to keep them in step. A mismatch between the first two is invisible except as rubber-banding; between the first and third the same key produces a different world depending on how you launched. **The camera went through walls**, had no reaction to a landing, and gave no sense of pace. **Nothing streamed reacted to the avatar being near it.** Picking was prim-only and edit-oriented; benches and shops were scenery you walked through. The `pois` and `addresses` layers were decoded on every streamed cell and thrown away — 467 and 6,211 features in the Groningen fixture (`docs/notes/vector-tile-unused-layers.md`). **There was no audio at all.** `bevy_audio` was dropped when the feature list was slimmed. A city you can walk through in silence reads as a viewer, not a place. ## Approach **A shared speed model.** `crates/core/src/motion.rs` holds what the sim, the prediction and the offline path must agree on — `sim_speed_for_wish` (`:40`), `GRAVITY = 22.0` (`:69`, above Earth's so a jump does not hang like the moon) and the integrated `step_fall` (`:94`). `systems::locomotion` holds the client's half: stroll 1.4 / jog 4.2 / sprint 8.0 m/s (`crates/viberfox/src/systems/player/locomotion.rs:31,34,38`) and the clip reference speeds. Gaits cost **nothing on the wire** — `apply_intent` derives speed from the wish vector's *magnitude*, so a gait is a magnitude the client picks. Speed ramps in m/s² rather than easing a normalized throttle, or a sprint start feels exactly as light as a walk start. **Clips matched to the ground.** `Walk` is loaded, the gait is chosen by ground speed with hysteresis, and playback rate is `ground_speed / clip_reference_speed` so the stride covers the distance travelled. Changes cross-fade through `AnimationTransitions` instead of `stop`/`play`. **Protocol 13** (`crates/core/src/protocol.rs:56`) for the two things that did *not* fit through the magnitude throttle. `ClientIntent::jump` is a request the sim grants only from the ground, so holding the bit cannot climb — a predicted jump is reconciled straight into the floor, since `SimWorld::step` clamps `y >= 0`. Emotes replicate as a *level* held on the wire for a couple of seconds so a client arriving mid-wave sees it, which is why they carry a sequence number. **The follow boom collides in 2D and only below 15 m** (`free_camera.rs:230`) — footprints carry no height, so above an eaves line a 2D hit means "there is a building over there", not "the view is blocked". In fast, out slow, or every doorway makes the camera lunge. A landing dips the eye scaled by impact speed; FOV widens toward a sprint. Driver mode is exempt. **The city answers back.** `crates/geo/src/places.rs` extracts the two dropped layers out of the `TileData` `map_geometry` already parses — no extra request, no extra decode. `systems::world_places` keys them by cell in the anchored world frame and drops them with the cell; `systems::interact` is the proximity prompt and the E key. Street furniture becomes circle colliders. **Audio synthesised at startup** (`systems::soundscape`) rather than shipped: `.gitattributes` tracks audio by extension, so a shipped footstep would be an LFS object with the documented silent-failure mode. Footsteps are counted off the same ground speed that sets the clip rate, so they stay in phase by construction. ## Acceptance criteria - [x] Walk / jog / sprint are distinct speeds; `Shift` sprints, `Alt` strolls - [x] The clip's playback rate tracks ground speed — no foot-slide at any gait - [x] Gait changes cross-fade rather than popping - [x] `Space` jumps from the ground and takes off if held past 0.25 s; the sim grants it, so prediction is not reconciled into the floor - [x] Falling is an integrated arc, identical in sim, prediction and `--offline` - [x] `1`–`6` play emotes, replicated so a client arriving mid-emote sees it - [x] The follow camera does not pass through buildings below 15 m - [x] Walking near a bench or a shop raises a prompt naming it; `E` acts on it - [x] Street furniture is solid; the playground token is deliberately *not* - [x] House numbers read out (numeric MVT values included — reading only `Value::String` silently dropped most) - [x] Footsteps, landings and wind play, all generated at startup - [x] `--no-default-features --features solo` still builds on a host with no ALSA ## Verification Run in this container on 2026-08-10, all green: ``` cargo check --workspace # clean cargo test -p viberfox # 284 passed cargo test -p viberfox_geo --lib # 91 passed cargo test -p viberfox_core # 23 passed cargo check -p viberfox --no-default-features --features solo # audio gate holds ``` **New system prerequisite:** the `audio` feature (on by default) pulls `bevy_audio → rodio → cpal → alsa-sys`, whose build script *panics* without `libasound2-dev` — it is a pkg-config shim with no vendored fallback. Any Linux builder needs the package, or `--no-default-features --features solo`. Installed in the agent container and added to its image definition on 2026-08-10. **Not verifiable here, needs a workstation:** this container has no GPU and no Vulkan ICD, so nothing was seen. The camera boom, the landing dip, the FOV coupling, the flight/jump pose and the interaction prompt's placement are all **visually unchecked**. `cargo shots` and a real walk around a city are the missing gate. ## Out of scope - **Strafe and turn clips.** The set contains `Run_Left`/`Run_Right`/`Run_Back`; the avatar turns to face its wish direction instead, deliberately. - **A flight clip.** Quaternius has nothing airborne, and the UAL library's cannot bind (different rig) — flight and jump are *poses* on the model holders, not clips. - **Animation events for footsteps.** The clips carry none and adding them means editing 20 glTFs; steps are counted from ground speed instead. - **Server-side building collision.** The sim holds no geometry; collision stays client-side prediction against streamed footprints. - **Terrain-aware collision.** Footprints have no height, which is what gates the camera test at 15 m and skips avatar collision while airborne. - **Sound design beyond the three sources.** No UI, ambience or vehicle audio. --- Branch: `feat/30-avatar-game-feel`
Author
Owner

Ready for review — branch feat/30-avatar-game-feel @ 28a9915.

PR: https://code.garage44.eu/jeroen/viberfox/compare/main...feat/30-avatar-game-feel

main moved three times while this was in flight (#28, #33, #35), so the branch
carries two merges of main. The one that mattered is #33, the POI-markers work:
it and this branch both read the tile's pois layer. Resolved by keeping both
modules — places answers "what can the avatar walk up to", pois answers "what
stands on the map" — with the reasoning in the merge commit and the note updated
to record three readers of one layer. Unifying them is a follow-up, not part of
this.

Also fixed here: the eight feature commits had never been through CI's fmt gate and
failed it at 14 sites (ef83f60). origin/main passes it clean, so they all
arrived with this work.

Verified on the merged tree

Gate Result
cargo fmt -p viberfox -p viberfox_geo -p viberfox_core -p viberfox_simulator -p viberfox_android --check OK
cargo check --workspace --all-targets clean
cargo test -p viberfox 292 passed, 0 failed
cargo test -p viberfox_geo 95 passed
cargo test -p viberfox_core 23 passed
cargo test -p big_space --lib 14 passed

Not verified, and cannot be here

  • Nothing has been looked at. No GPU and no Vulkan ICD in the agent container,
    so the camera boom, the landing dip, the FOV coupling, the flight/jump pose and
    the interaction prompt's placement are unseen. cargo shots on a workstation is
    the missing gate.
  • The Android check (no NDK here) and scene-smoke (no GPU).

New build prerequisite

The audio feature (default on) pulls bevy_audio -> rodio -> cpal -> alsa-sys,
whose build script panics without libasound2-dev — a pkg-config shim with no
vendored fallback. Any Linux builder needs the package, or
--no-default-features --features solo.

**Ready for review — branch `feat/30-avatar-game-feel` @ `28a9915`.** PR: https://code.garage44.eu/jeroen/viberfox/compare/main...feat/30-avatar-game-feel `main` moved three times while this was in flight (#28, #33, #35), so the branch carries two merges of `main`. The one that mattered is #33, the POI-markers work: it and this branch both read the tile's `pois` layer. Resolved by keeping both modules — `places` answers "what can the avatar walk up to", `pois` answers "what stands on the map" — with the reasoning in the merge commit and the note updated to record three readers of one layer. **Unifying them is a follow-up, not part of this.** Also fixed here: the eight feature commits had never been through CI's fmt gate and failed it at 14 sites (`ef83f60`). `origin/main` passes it clean, so they all arrived with this work. ### Verified on the merged tree | Gate | Result | |---|---| | `cargo fmt -p viberfox -p viberfox_geo -p viberfox_core -p viberfox_simulator -p viberfox_android --check` | OK | | `cargo check --workspace --all-targets` | clean | | `cargo test -p viberfox` | **292 passed**, 0 failed | | `cargo test -p viberfox_geo` | 95 passed | | `cargo test -p viberfox_core` | 23 passed | | `cargo test -p big_space --lib` | 14 passed | ### Not verified, and cannot be here - **Nothing has been looked at.** No GPU and no Vulkan ICD in the agent container, so the camera boom, the landing dip, the FOV coupling, the flight/jump pose and the interaction prompt's placement are unseen. `cargo shots` on a workstation is the missing gate. - The Android check (no NDK here) and `scene-smoke` (no GPU). ### New build prerequisite The `audio` feature (default on) pulls `bevy_audio -> rodio -> cpal -> alsa-sys`, whose build script panics without `libasound2-dev` — a pkg-config shim with no vendored fallback. Any Linux builder needs the package, or `--no-default-features --features solo`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jeroen/cartopolis#30
No description provided.