Skip to content

Commit a086c36

Browse files
committed
fix: implement server-side speed validation for paddle movement
1 parent ec21bf0 commit a086c36

6 files changed

Lines changed: 114 additions & 147 deletions

File tree

ARCHITECTURE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ Each game match runs in a Cloudflare **Durable Object** (DO). The DO maintains t
5757
- **Tick Loop:** The server calls `GameState::step` which delegates to `game_core::step`.
5858
- **Broadcasting:** Every 3rd tick (20Hz), it sends a snapshot to all clients via `broadcast_state`.
5959

60+
> [!NOTE]
61+
> **Edge Latency Nuance:** While Durable Objects run "at the edge," each specific match runs in a **single location**. Frame updates still suffer light-speed latency for players far from that specific data center. Global latency is mitigated by region-aware matchmaking, ensuring players match in a DO close to both of them.
62+
6063
### 3. The Client (`client_wasm`)
6164

6265
The client needs to be smooth (120Hz+) even though headers only arrive at 20Hz.
@@ -122,7 +125,7 @@ graph TD
122125
1. Browser captures key press in [`on_key_down`](client_wasm/src/lib.rs).
123126
2. Client updates local paddle immediately.
124127
3. Client sends `C2S::Input` to server.
125-
4. Server applies input in its next tick.
128+
4. Server validates input (enforcing speed limits) and updates entity intent.
126129
5. Server includes new paddle position in next broadcast.
127130

128131
### Rendering Frame
@@ -154,7 +157,7 @@ graph TD
154157
```rust
155158
enum C2S {
156159
Join { code: [u8; 5] },
157-
Input { player_id: u8, paddle_dir: i8, seq: u32 },
160+
Input { player_id: u8, y: f32, seq: u32 },
158161
Ping { t_ms: u32 },
159162
}
160163
```

ARTICLE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ The server advances the simulation at ~60Hz but only broadcasts authoritative sn
120120
The server is the authority.
121121
If the client thinks the ball is at `x = 100` but the server says `x = 102`, the server wins. Always.
122122

123+
For paddles, the server uses **Target-Based Validation**. It accepts the client's desired position but moves the authoritative paddle towards that target at the maximum allowed speed. This prevents teleportation cheats while maintaining responsiveness.
124+
123125
---
124126

125127
## 3. Client: Prediction and Reconciliation

game_core/src/components.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,32 @@ impl Ball {
4343
}
4444

4545
/// Movement intent for paddle
46-
#[derive(Debug, Clone, Copy, Default)]
46+
#[derive(Debug, Clone, Copy)]
4747
pub struct PaddleIntent {
48-
pub dir: i8, // -1 = up, 0 = stop, 1 = down
48+
pub dir: i8, // Deprecated: Only used for legacy/client prediction hints if needed
49+
pub target_y: f32, // Desired Y position
50+
}
51+
52+
impl Default for PaddleIntent {
53+
fn default() -> Self {
54+
Self {
55+
dir: 0,
56+
target_y: 12.0, // Center default
57+
}
58+
}
4959
}
5060

5161
impl PaddleIntent {
5262
pub fn new() -> Self {
5363
Self::default()
5464
}
65+
66+
pub fn with_target(y: f32) -> Self {
67+
Self {
68+
dir: 0,
69+
target_y: y,
70+
}
71+
}
5572
}
5673

5774
#[cfg(test)]

game_core/src/lib.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ pub fn step(
8181

8282
/// Helper to create a paddle entity
8383
pub fn create_paddle(world: &mut World, player_id: u8, y: f32) -> hecs::Entity {
84-
world.spawn((Paddle::new(player_id, y), PaddleIntent::new()))
84+
world.spawn((Paddle::new(player_id, y), PaddleIntent::with_target(y)))
8585
}
8686

8787
/// Helper to create the ball entity
@@ -212,13 +212,16 @@ mod integration_tests {
212212
&mut respawn_state,
213213
);
214214

215-
// Verify paddle moved
215+
// Verify paddle moved towards target
216216
for (_entity, paddle) in world.query::<&Paddle>().iter() {
217217
if paddle.player_id == 0 {
218+
// Should have moved UP (smaller Y) towards 5.0
218219
assert!(
219220
paddle.y < initial_paddle_y,
220221
"Paddle should move up after input"
221222
);
223+
// Should not overshoot target if speed allows
224+
// (Depends on speed/dt, but 12.0 -> 5.0 is far, so it should just be closer)
222225
}
223226
}
224227
}

game_core/src/systems/input.rs

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,17 @@
1-
use crate::{NetQueue, Paddle};
1+
use crate::{NetQueue, Paddle, PaddleIntent};
22
use hecs::World;
33

4-
/// Ingest network inputs and apply to paddle positions
4+
/// Ingest network inputs and apply by updating paddle targets
55
pub fn ingest_inputs(world: &mut World, net_queue: &mut NetQueue) {
66
// Process all queued inputs
77
for (player_id, y_pos) in net_queue.inputs.drain(..) {
8-
// Find paddle with matching player_id
9-
for (_entity, paddle) in world.query_mut::<&mut Paddle>() {
8+
// Find paddle and intent with matching player_id
9+
for (_entity, (paddle, intent)) in world.query_mut::<(&Paddle, &mut PaddleIntent)>() {
1010
if paddle.player_id == player_id {
11-
// Apply absolute position (clamped to arena)
11+
// Update target, clamped to valid arena range for the center of the paddle
1212
// Arena height is 24.0, paddle height 4.0.
13-
// Valid range: 2.0 to 22.0 (center pos)
14-
// Wait, previous code used Clamp(2.0, 22.0)?
15-
// Let's check previous CLAMP values used in client:
16-
// client.local_paddle_y.clamp(half_height, ARENA_HEIGHT - half_height);
17-
// half_height = 2.0. Arena = 24.0. So 2.0 to 22.0 is center position range.
18-
paddle.y = y_pos.clamp(2.0, 22.0);
13+
// Valid center range: 2.0 to 22.0
14+
intent.target_y = y_pos.clamp(2.0, 22.0);
1915
}
2016
}
2117
}
@@ -42,16 +38,16 @@ mod tests {
4238

4339
ingest_inputs(&mut world, &mut net_queue);
4440

45-
// Verify positions were applied correctly
46-
let mut paddle_y = Vec::new();
47-
for (_entity, paddle) in world.query::<&Paddle>().iter() {
48-
paddle_y.push((paddle.player_id, paddle.y));
41+
// Verify targets were updated correctly
42+
let mut paddle_targets = Vec::new();
43+
for (_entity, (paddle, intent)) in world.query::<(&Paddle, &PaddleIntent)>().iter() {
44+
paddle_targets.push((paddle.player_id, intent.target_y));
4945
}
50-
paddle_y.sort_by_key(|(id, _)| *id);
46+
paddle_targets.sort_by_key(|(id, _)| *id);
5147

52-
assert_eq!(paddle_y.len(), 2);
53-
assert_eq!(paddle_y[0], (0, 5.0));
54-
assert_eq!(paddle_y[1], (1, 18.0));
48+
assert_eq!(paddle_targets.len(), 2);
49+
assert_eq!(paddle_targets[0], (0, 5.0));
50+
assert_eq!(paddle_targets[1], (1, 18.0));
5551
}
5652

5753
#[test]
@@ -79,9 +75,11 @@ mod tests {
7975

8076
ingest_inputs(&mut world, &mut net_queue);
8177

82-
// Last input should be applied
83-
for (_entity, paddle) in world.query::<&Paddle>().iter() {
84-
assert_eq!(paddle.y, 8.0, "Last input should be applied");
78+
// Last input target should be applied
79+
for (_entity, (paddle, intent)) in world.query::<(&Paddle, &PaddleIntent)>().iter() {
80+
if paddle.player_id == 0 {
81+
assert_eq!(intent.target_y, 8.0, "Last input target should be applied");
82+
}
8583
}
8684
}
8785

@@ -92,14 +90,18 @@ mod tests {
9290

9391
net_queue.push_input(0, -100.0); // Too low
9492
ingest_inputs(&mut world, &mut net_queue);
95-
for (_entity, paddle) in world.query::<&Paddle>().iter() {
96-
assert_eq!(paddle.y, 2.0, "Should clamp to min");
93+
for (_entity, (paddle, intent)) in world.query::<(&Paddle, &PaddleIntent)>().iter() {
94+
if paddle.player_id == 0 {
95+
assert_eq!(intent.target_y, 2.0, "Should clamp target to min");
96+
}
9797
}
9898

9999
net_queue.push_input(0, 100.0); // Too high
100100
ingest_inputs(&mut world, &mut net_queue);
101-
for (_entity, paddle) in world.query::<&Paddle>().iter() {
102-
assert_eq!(paddle.y, 22.0, "Should clamp to max");
101+
for (_entity, (paddle, intent)) in world.query::<(&Paddle, &PaddleIntent)>().iter() {
102+
if paddle.player_id == 0 {
103+
assert_eq!(intent.target_y, 22.0, "Should clamp target to max");
104+
}
103105
}
104106
}
105107

0 commit comments

Comments
 (0)