From 878b1f4aee5dfde01d0ee58889df1640da47446e Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 17 Apr 2026 19:27:10 -0400 Subject: [PATCH] NEO-22: fix stream rubber-banding (coalesce, idle gate, soft snap) Queue only latest move-stream target per POST; clear pending while busy. Skip stream submits when idle; skip tiny authoritative snaps after boot. --- client/README.md | 2 +- client/scripts/main.gd | 29 ++++++++++++++++++--- client/scripts/position_authority_client.gd | 20 +++++++------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/client/README.md b/client/README.md index 3c0a3b8..7738c28 100644 --- a/client/README.md +++ b/client/README.md @@ -53,7 +53,7 @@ Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screen ## Authoritative movement (NEO-7, NEO-11, NEO-22) -With the game server running ([`server/README.md`](../server/README.md)), **WASD** (input actions **`move_*`** in `project.godot`) drives **camera-relative XZ** wish direction on the **`Player`**, and **`main.gd`** periodically **`POST`**s **`/game/players/{id}/move-stream`** with a batch of **absolute** world positions (NEO-22 **option C**). The server validates each leg with the same rules as a single **`MoveCommand`**, applies **all targets in order** (reject = **no** partial apply), and returns **`PositionStateResponse`**; the client **snaps** to that position. **`POST /game/players/{id}/move`** remains for **debug** click-to-move when running a **debug** Godot build (`ground_pick.gd` only listens in **`OS.is_debug_build()`**): it still does **POST** + **`GET`** verify and sets a **nav walk goal** on the player. +With the game server running ([`server/README.md`](../server/README.md)), **WASD** (input actions **`move_*`** in `project.godot`) drives **camera-relative XZ** wish direction on the **`Player`**, and **`main.gd`** periodically **`POST`**s **`/game/players/{id}/move-stream`** with the **latest** body-centre sample while you are moving (no stream while standing still, so idle anchor does not fight HTTP). **`PositionAuthorityClient`** coalesces to **one target per request** so a slow round-trip cannot apply a stale multi-step chain after the capsule has moved. The server validates each leg with the same rules as a single **`MoveCommand`**, applies **all targets in order** (reject = **no** partial apply), and returns **`PositionStateResponse`**; the client **snaps** when the correction exceeds a small threshold (boot snap always applies once). **`POST /game/players/{id}/move`** remains for **debug** click-to-move when running a **debug** Godot build (`ground_pick.gd` only listens in **`OS.is_debug_build()`**): it still does **POST** + **`GET`** verify and sets a **nav walk goal** on the player. **Boot** `sync_from_server()` **snaps** once so spawn matches the server. diff --git a/client/scripts/main.gd b/client/scripts/main.gd index ca0cff8..97e6ed5 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -23,9 +23,18 @@ const MOVE_REJECT_MSG_SECONDS: float = 4.0 ## Throttle `move-stream` POSTs (~20 Hz at 120 physics ticks/sec). const STREAM_PHYSICS_INTERVAL: int = 6 +## Skip `move-stream` while standing still so idle + anchor are not fighting HTTP snaps. +const STREAM_IDLE_VEL_HZ_SQ: float = 0.0004 + +## Ignore authority snaps smaller than this (m) to avoid micro-flicker from float / echo noise. +const AUTH_SNAP_MIN_HORIZONTAL: float = 0.08 +const AUTH_SNAP_MIN_VERTICAL: float = 0.04 + ## Bump on each rejection so older one-shot timers do not clear a newer message. var _move_reject_msg_token: int = 0 var _stream_physics_counter: int = 0 +## After the first `apply_as_snap` authority update, micro-snaps may be skipped (boot always applies). +var _allow_authority_soft_snap_skip: bool = false ## [InputMap] [code]dev_toggle_occluder_obstacle[/code] (default **Ctrl+Shift+K**): ## Restored when the obstacle is shown again. @@ -75,11 +84,16 @@ func _physics_process(_delta: float) -> void: return var wish: Vector3 = _locomotion_wish_world_xz_from_camera() _player.call("set_locomotion_wish_world_xz", wish) - _stream_physics_counter += 1 - if _stream_physics_counter >= STREAM_PHYSICS_INTERVAL: + var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared() + var moving: bool = wish.length_squared() > 1e-10 or vel_hz_sq > STREAM_IDLE_VEL_HZ_SQ + if moving: + _stream_physics_counter += 1 + if _stream_physics_counter >= STREAM_PHYSICS_INTERVAL: + _stream_physics_counter = 0 + var batch: Array = [_player.global_position] + _authority.call("submit_stream_targets", batch) + else: _stream_physics_counter = 0 - var batch: Array = [_player.global_position] - _authority.call("submit_stream_targets", batch) var p: Vector3 = _player.global_position _player_pos_label.text = "x: %.3f\ny: %.3f\nz: %.3f" % [p.x, p.y, p.z] @@ -111,6 +125,13 @@ func _on_target_chosen(world: Vector3) -> void: func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void: if apply_as_snap: + if _allow_authority_soft_snap_skip: + var p: Vector3 = _player.global_position + var dh: float = Vector2(world.x - p.x, world.z - p.z).length() + var dy: float = absf(world.y - p.y) + if dh < AUTH_SNAP_MIN_HORIZONTAL and dy < AUTH_SNAP_MIN_VERTICAL: + return + _allow_authority_soft_snap_skip = true _player.snap_to_server(world) else: _player.set_authoritative_nav_goal(world) diff --git a/client/scripts/position_authority_client.gd b/client/scripts/position_authority_client.gd index 06ebc49..dd1c9b3 100644 --- a/client/scripts/position_authority_client.gd +++ b/client/scripts/position_authority_client.gd @@ -58,8 +58,13 @@ func submit_move_target(world: Vector3) -> void: _start_move_post(world) -## NEO-22: append samples; sends up to [constant MOVE_STREAM_MAX_PER_REQUEST] per POST when idle. +## NEO-22: append samples; each POST sends **only the latest** queued position so slow HTTP cannot +## apply a stale multi-step chain after the capsule has already moved (rubber-band / bounce). func submit_stream_targets(targets: Array) -> void: + if _busy: + # In-flight POST already encodes an older chain; drop queued crumbs so we do not replay + # past intent after this response lands. + _stream_queue.clear() for t: Variant in targets: if t is Vector3: _stream_queue.append(t as Vector3) @@ -72,11 +77,9 @@ func _try_flush_pending() -> void: if _busy: return if not _stream_queue.is_empty(): - var n: int = mini(_stream_queue.size(), MOVE_STREAM_MAX_PER_REQUEST) - var batch: Array = [] - for i in range(n): - batch.append(_stream_queue[i]) - _start_stream_post(batch) + var latest: Vector3 = _stream_queue[_stream_queue.size() - 1] + _stream_queue.clear() + _start_stream_post([latest]) return if _pending_move_target is Vector3: var w: Vector3 = _pending_move_target as Vector3 @@ -200,10 +203,7 @@ func _on_request_completed( _busy = false _try_flush_pending() return - # Drop the batch we sent (validated + applied as a unit on the server). - var drop: int = mini(_stream_in_flight_count, _stream_queue.size()) - if drop > 0: - _stream_queue = _stream_queue.slice(drop) + # Queue was cleared when the POST started; nothing to drop. _stream_in_flight_count = 0 _busy = false if response_code == 200: