current state

This commit is contained in:
2026-09-18 22:41:20 +02:00
parent 5cc156992f
commit a2196feb78
14 changed files with 823 additions and 170 deletions
Generated
+4
View File
@@ -854,6 +854,10 @@ dependencies = [
"jni-sys 0.3.0",
]
[[package]]
name = "netsim"
version = "0.1.0"
[[package]]
name = "num-traits"
version = "0.2.19"
+1 -1
View File
@@ -1,3 +1,3 @@
[workspace]
members = ["shared", "client", "server"]
members = ["shared", "client", "server", "netsim"]
resolver = "2"
+245 -82
View File
@@ -6,33 +6,36 @@ use std::collections::{HashMap, VecDeque};
use crate::assets::Image;
use crate::input::{GameAction, InputState};
use crate::net::{EntityInfo, NetClient, NetEvent};
use shared::{chunk_id, player_action, tile_collidable};
use shared::{chunk_id, delta_action, player_action, step_allowed, tile_collidable, TICKS_PER_MOVE};
pub enum GameSignal {
Quit,
}
/// The cardinal action that moves `from` → `to`. This is the only movement vocabulary
/// the server understands; the pathfinder's output is translated through here.
fn step_action(from: (i32, i32), to: (i32, i32)) -> u16 {
match (to.0 - from.0, to.1 - from.1) {
(0, -1) => player_action::NORTH,
(0, 1) => player_action::SOUTH,
(-1, 0) => player_action::WEST,
(1, 0) => player_action::EAST,
d => unreachable!("non-cardinal step {d:?}"),
}
}
/// One queued step per this interval while a direction is held. Mirrors the server's
/// movement cadence (24 Hz base tick, movement on every 4th tick → 6 Hz ≈ 167 ms/tile).
/// The server's movement cadence (24 Hz base tick, movement on every 4th tick
/// → 6 Hz ≈ 167 ms/tile). Entity interpolation lerps over this interval.
const MOVE_INTERVAL_MS: usize = 167;
/// After this long without movement input, drop any unconfirmed queued path (the server
/// is the truth; whatever it hasn't acted on is discarded).
const RECONCILE_IDLE_MS: usize = 300;
/// Length of one server tick. Steps are not paced by a local timer but scheduled onto
/// the server's tick timeline, estimated from the last `StatePacket` plus elapsed time.
const TICK_MS: f32 = 1000.0 / shared::TICK_HZ as f32;
/// Upper bound on queued-but-unconfirmed steps, so a server-side block can't grow it forever.
/// Length of one movement window.
const WINDOW_MS: f32 = TICK_MS * TICKS_PER_MOVE as f32;
/// Safety margin on top of the measured RTT when choosing the scheduling lead — covers
/// jitter and the server answering pings on its tick grid.
const LEAD_MARGIN_MS: f32 = 50.0;
/// RTT sampling interval; the measurement drives the scheduling lead.
const PING_INTERVAL_MS: usize = 1000;
/// Upper bound on queued-but-unconfirmed steps — purely a prediction bound: window
/// addressing already caps what the server will hold (windows are consecutive and only
/// `sched_lead` ahead), while confirmations lag a full RTT behind, so on a slow link
/// several correct steps are legitimately in flight at once. Steps whose window the
/// acknowledgment cursor has passed expire automatically, so the path can never go
/// permanently stale.
const PATH_MAX_LEN: usize = 8;
/// Palette index for the path indicator. Pure blue in RGB332 (r=0, g=0, b=3).
@@ -62,6 +65,16 @@ struct EntityLerp {
t_ms: usize,
}
/// One sent-but-unacknowledged step: the movement window it is scheduled for and its
/// *delta* — deliberately not an absolute tile. The server executes deltas, so the
/// prediction is derived by replaying pending deltas on top of the last authoritative
/// position: a server surprise shifts the whole prediction instead of invalidating it.
/// The window is both the retraction address and the expiry key against the ack cursor.
struct PathStep {
window: u32,
delta: (i32, i32),
}
pub struct Game {
#[allow(dead_code)]
tileset: Vec<[u8; 64]>,
@@ -70,17 +83,30 @@ pub struct Game {
player_entity_id: u32,
/// Authoritative position, as last confirmed by the server.
player_pos: (i32, i32),
/// Queued future tiles: steps sent to the server but not yet confirmed. Shown as the
/// Queued future steps: sent to the server but not yet confirmed. Shown as the
/// planned path; the head is consumed as the server confirms each move.
path: VecDeque<(i32, i32)>,
path: VecDeque<PathStep>,
/// Planned route from click-to-move: tiles not yet sent to the server. Fed into
/// `path` one step per `MOVE_INTERVAL_MS`. Keyboard input cancels it.
/// `path` one step per movement window. Keyboard input cancels it.
route: VecDeque<(i32, i32)>,
/// The persistent movement goal. Outlives the route: any surprise (blocked step,
/// server divergence) triggers a replan toward it, and while the mouse is held it
/// tracks the tile under the cursor. Cleared on arrival, unreachability, keyboard
/// override, or a click on an unreachable tile.
goal: Option<(i32, i32)>,
/// Tick clock: the last tick announced by a `StatePacket` and how long ago it
/// arrived — together an estimate of the server's current tick.
server_tick: u32,
tick_age_ms: usize,
/// Smoothed round-trip time from the automatic pings; `0` until the first pong.
rtt_ms: f32,
ping_accum_ms: usize,
/// Highest movement window a step was scheduled for. At most one step is scheduled
/// per window; retraction lowers this so freed windows can be re-addressed.
last_sched_window: u32,
/// Viewport top-left in world pixels. Follows the player linearly instead of
/// snapping tile-to-tile; only rendering rounds it to whole pixels.
cam: (f32, f32),
move_accum_ms: usize,
idle_ms: usize,
entities: Vec<EntityInfo>,
/// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game
/// logic keeps using the authoritative tile positions.
@@ -88,12 +114,10 @@ pub struct Game {
}
impl Game {
pub fn start() -> Self {
pub fn start(server_addr: std::net::SocketAddr) -> Self {
let image = Image::from_tga("assets/tilesets/overworld.tga");
let tileset = image.to_tileset();
let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset();
let server_addr = "127.0.0.1:7777".parse().unwrap();
let player_pos = (16, 16);
Game {
tileset,
@@ -103,12 +127,16 @@ impl Game {
player_pos,
path: VecDeque::new(),
route: VecDeque::new(),
goal: None,
server_tick: 0,
tick_age_ms: 0,
rtt_ms: 0.0,
ping_accum_ms: PING_INTERVAL_MS, // first ping fires immediately
last_sched_window: 0,
cam: (
(player_pos.0 * TILE_PX - VIEW_PX / 2) as f32,
(player_pos.1 * TILE_PX - VIEW_PX / 2) as f32,
),
move_accum_ms: MOVE_INTERVAL_MS, // ready, so the first held step fires instantly
idle_ms: 0,
entities: Vec::new(),
lerp: HashMap::new(),
}
@@ -130,11 +158,21 @@ impl Game {
pub fn update(&mut self, render_frame: &mut [u8], dt: usize, input: &InputState)
-> Option<GameSignal>
{
if input.mouse_clicked() {
self.handle_click(input.mouse_pos());
self.tick_age_ms += dt;
// Periodic RTT sampling — the measurement drives how far ahead steps are
// scheduled (`sched_lead`), so it has to stay current on a changing link.
self.ping_accum_ms += dt;
if self.ping_accum_ms >= PING_INTERVAL_MS {
self.ping_accum_ms = 0;
self.net.send_ping();
}
self.step_movement(dt, input);
if input.mouse_clicked() || input.mouse_held() {
self.handle_click(input.mouse_pos(), !input.mouse_clicked());
}
self.step_movement(input);
if input.button_pressed(GameAction::Confirm) {
self.net.send_ping();
@@ -143,18 +181,26 @@ impl Game {
for event in self.net.poll() {
match event {
NetEvent::Pong { rtt_ms } => println!("pong! rtt = {rtt_ms} ms"),
NetEvent::Pong { rtt_ms } => {
self.rtt_ms = if self.rtt_ms == 0.0 {
rtt_ms as f32
} else {
0.8 * self.rtt_ms + 0.2 * rtt_ms as f32
};
}
NetEvent::State { tick, player_entity_id } => {
println!("state tick={tick} player_entity_id={player_entity_id}");
self.player_entity_id = player_entity_id;
self.server_tick = tick;
self.tick_age_ms = 0;
}
NetEvent::Chunk { chunk_id } => println!("chunk cached id={chunk_id}"),
NetEvent::Entity { entities } => {
NetEvent::Entity { tick, entities } => {
self.player_pos = entities.iter()
.find(|e| e.id == self.player_entity_id)
.map(|e| (e.pos_x as i32, e.pos_y as i32))
.unwrap_or(self.player_pos);
self.reconcile_path();
self.reconcile_path(tick);
self.track_lerp(&entities);
self.entities = entities;
}
@@ -173,10 +219,47 @@ impl Game {
None
}
/// The position movement continues from: the last queued step, or where the server
/// last put us.
/// Replay one pending delta the way the server will: through the shared movement
/// rule. A delta the server is going to reject does not move the prediction either —
/// so the prediction can never sit inside a wall, however far position and pending
/// steps have diverged.
fn replay_step(&self, p: (i32, i32), delta: (i32, i32)) -> (i32, i32) {
let to = (p.0 + delta.0, p.1 + delta.1);
if step_allowed(p, to, |x, y| self.tile_blocked(x, y)) { to } else { p }
}
/// The position movement continues from: the last authoritative position with every
/// pending delta replayed on top. Never stored — always derived, so it follows the
/// server automatically when a step was rejected or executed elsewhere.
fn predicted_pos(&self) -> (i32, i32) {
self.path.back().copied().unwrap_or(self.player_pos)
self.path.iter()
.fold(self.player_pos, |p, s| self.replay_step(p, s.delta))
}
/// Estimated current server tick: the last announced tick advanced by local elapsed
/// time. Broadcast latency makes this lag slightly behind the real server clock —
/// scheduling one window ahead plus the server's lateness rule absorbs that.
fn est_tick(&self) -> f32 {
self.server_tick as f32 + self.tick_age_ms as f32 / TICK_MS
}
/// The movement window the estimated server time is currently in.
fn est_window(&self) -> u32 {
(self.est_tick() / TICKS_PER_MOVE as f32) as u32
}
/// How many windows ahead to schedule so actions arrive *before* their window. The
/// tick estimate lags by the downlink and the action spends the uplink in transit —
/// together one RTT — so the lead must cover the RTT plus a jitter margin. At least
/// one: never schedule into the current window.
fn sched_lead(&self) -> u32 {
(((self.rtt_ms + LEAD_MARGIN_MS) / WINDOW_MS).ceil() as u32).max(1)
}
/// The newest window whose scheduled step can no longer be retracted in time: a
/// cancellation sent now needs the same lead an action does.
fn retract_horizon(&self) -> u32 {
self.est_window() + self.sched_lead() - 1
}
/// Where the camera wants to be: the viewport centered on the player's tile.
@@ -207,40 +290,76 @@ impl Game {
self.cam = (approach(self.cam.0, tx), approach(self.cam.1, ty));
}
/// Click-to-move: translate a framebuffer click into a world tile and plan a route
/// there. The route is only a client-side plan — it is executed as ordinary cardinal
/// actions in `step_movement`, so the server keeps full authority over every step.
fn handle_click(&mut self, (mx, my): (i32, i32)) {
/// Click-to-move: translate a framebuffer click into a world tile and adopt it as
/// the movement goal. The route is only a client-side plan — executed as ordinary
/// tick-addressed actions in `step_movement`, so the server keeps full authority.
/// With `hold` (button held after the initial click) this runs every frame and keeps
/// steering toward the tile under the cursor, replanning only when that tile changes
/// — camera movement alone shifts it too, not just moving the mouse.
fn handle_click(&mut self, (mx, my): (i32, i32), hold: bool) {
if mx < 0 || my < 0 || mx >= VIEW_PX || my >= VIEW_PX {
return; // outside the world viewport
}
let (cx, cy) = self.cam_px();
let goal = ((cx + mx).div_euclid(TILE_PX), (cy + my).div_euclid(TILE_PX));
let found = pathfind::find_path(self.predicted_pos(), goal, |x, y| self.tile_blocked(x, y));
match found {
Some(steps) => self.route = steps.into(),
None => self.route.clear(), // unreachable — cancel any current route
// While steering, an unchanged goal needs no replan — unless the route was voided
// (blocked step) before reaching it; an empty route with the goal still ahead of
// the prediction means exactly that, so plan again.
if hold && self.goal == Some(goal)
&& (!self.route.is_empty() || self.predicted_pos() == goal)
{
return;
}
// Plan from where the player will be once the retractable steps are withdrawn:
// the confirmed position plus the deltas that can no longer be cancelled in
// time — not the tip of a prediction we are about to cancel.
let horizon = self.retract_horizon();
let start = self.path.iter()
.take_while(|s| s.window <= horizon)
.fold(self.player_pos, |p, s| self.replay_step(p, s.delta));
match pathfind::find_path(start, goal, |x, y| self.tile_blocked(x, y)) {
Some(steps) => {
// The plan changed: retract every scheduled-but-unexecuted step so the
// old intent cannot keep playing out on the server.
self.retract_future_steps();
self.route = steps.into();
self.goal = Some(goal);
}
// A discrete click on an unreachable tile cancels the plan; while steering,
// sweeping the cursor across a blocked tile keeps the current plan alive.
None if !hold => {
self.route.clear();
self.goal = None;
}
None => {}
}
}
/// Movement queueing. A held direction key (which cancels any planned route) or the
/// next planned route tile appends one step per `MOVE_INTERVAL_MS` to the in-flight
/// path, checking walkability locally and forwarding the cardinal action to the
/// server. When idle, drop any still-unconfirmed path.
fn step_movement(&mut self, dt: usize, input: &InputState) {
let dir = if input.button_held(GameAction::Up) { Some((0, -1)) }
else if input.button_held(GameAction::Down) { Some((0, 1)) }
else if input.button_held(GameAction::Left) { Some((-1, 0)) }
else if input.button_held(GameAction::Right) { Some((1, 0)) }
else { None };
/// Movement scheduling on the server timeline. A held direction key (which cancels
/// any goal) or the next planned route tile is addressed to the *next* movement
/// window — at most one step per window, so the send rate follows the server's
/// cadence by construction instead of racing it with a local timer. Steps are
/// validated locally with the shared movement rule; a blocked route step triggers
/// a replan toward the goal.
fn step_movement(&mut self, input: &InputState) {
// Both axes combine, so two held keys walk diagonally (a king move).
let kx = input.button_held(GameAction::Right) as i32
- input.button_held(GameAction::Left) as i32;
let ky = input.button_held(GameAction::Down) as i32
- input.button_held(GameAction::Up) as i32;
let dir = (kx != 0 || ky != 0).then_some((kx, ky));
if dir.is_some() {
self.route.clear(); // manual input overrides click-to-move
// Manual input overrides click-to-move entirely, goal included.
self.route.clear();
self.goal = None;
}
self.move_accum_ms = (self.move_accum_ms + dt).min(MOVE_INTERVAL_MS);
let ready = self.move_accum_ms >= MOVE_INTERVAL_MS && self.path.len() < PATH_MAX_LEN;
let target = self.est_window() + self.sched_lead();
let ready = target > self.last_sched_window && self.path.len() < PATH_MAX_LEN;
let from = self.predicted_pos();
// The next step: either from the held key, or the head of the planned route.
@@ -249,29 +368,56 @@ impl Game {
None => self.route.front().copied(),
};
if let Some((nx, ny)) = step {
self.idle_ms = 0;
if ready {
// Route steps are re-validated at send time: the world may have changed
// since planning (or the plan may have desynced). A bad step voids the
// whole route rather than walking blindly on.
let adjacent = (nx - from.0).abs() + (ny - from.1).abs() == 1;
if adjacent && !self.tile_blocked(nx, ny) {
if let Some((nx, ny)) = step
&& ready
{
let allowed = step_allowed(from, (nx, ny), |x, y| self.tile_blocked(x, y));
if allowed {
if dir.is_none() {
self.route.pop_front();
}
self.path.push_back((nx, ny));
self.net.send_action(step_action(from, (nx, ny)));
self.path.push_back(PathStep { window: target, delta: (nx - from.0, ny - from.1) });
self.net.send_action(
delta_action(nx - from.0, ny - from.1),
target * TICKS_PER_MOVE,
);
self.last_sched_window = target;
} else if dir.is_none() {
self.replan_route();
}
}
}
/// Retract queued steps the server has (very likely) not executed yet: every step
/// addressed past the current estimated window gets a NOOP sent to its slot
/// (replacement = cancellation) and leaves the prediction. Steps at or before the
/// current window are left to be confirmed — cancelling those would race their
/// execution. Freed windows become addressable again.
fn retract_future_steps(&mut self) {
let horizon = self.retract_horizon();
while let Some(step) = self.path.back() {
if step.window <= horizon {
break;
}
self.net.send_action(player_action::NOOP, step.window * TICKS_PER_MOVE);
self.path.pop_back();
}
self.last_sched_window = self.path.back().map_or(horizon, |s| s.window);
}
/// Replan the route from the current prediction toward the persistent goal — the
/// client-side reaction to any surprise (blocked step, server divergence). Gives
/// the goal up only when it is reached or has become unreachable.
fn replan_route(&mut self) {
self.route.clear();
let Some(goal) = self.goal else { return };
if self.predicted_pos() == goal {
self.goal = None;
return;
}
self.move_accum_ms = 0;
}
} else {
self.idle_ms += dt;
if self.idle_ms >= RECONCILE_IDLE_MS {
self.path.clear();
}
match pathfind::find_path(self.predicted_pos(), goal, |x, y| self.tile_blocked(x, y)) {
Some(steps) => self.route = steps.into(),
None => self.goal = None,
}
}
@@ -316,11 +462,20 @@ impl Game {
}
}
/// Reconcile the queued path against a fresh authoritative position: drop every queued
/// tile up to and including the one the server has now reached.
fn reconcile_path(&mut self) {
if let Some(idx) = self.path.iter().position(|&p| p == self.player_pos) {
self.path.drain(0..=idx);
/// Fold a fresh authoritative snapshot into the prediction. The snapshot's tick is
/// an acknowledgment cursor on the shared timeline: every movement window at or
/// before it has been consumed — executed, rejected or lost, it no longer matters
/// which — so those steps expire, and the remaining deltas replay on top of the
/// confirmed position (`predicted_pos`). A server surprise thus *shifts* the
/// prediction instead of invalidating it; if the shifted route no longer connects
/// or misses the goal, send-time validation triggers a replan toward the goal.
fn reconcile_path(&mut self, tick: u32) {
let ack_window = tick / TICKS_PER_MOVE;
while self.path.front().is_some_and(|s| s.window <= ack_window) {
self.path.pop_front();
}
if self.path.is_empty() && self.route.is_empty() && self.goal == Some(self.player_pos) {
self.goal = None; // arrived — a later server correction must not walk us back
}
}
@@ -363,7 +518,15 @@ impl Game {
// Path pass — in-flight steps bright, planned-route tiles dim; over the world,
// under entities.
let marks = self.path.iter().map(|&p| (p, PATH_COLOR))
// Path tiles are derived by replaying the pending deltas from the confirmed
// position — the same fold the prediction uses (a rejected step marks in place).
let mut acc = self.player_pos;
let marks = self.path.iter()
.map(|s| {
acc = self.replay_step(acc, s.delta);
(acc, PATH_COLOR)
})
.collect::<Vec<_>>().into_iter()
.chain(self.route.iter().map(|&p| (p, ROUTE_COLOR)));
for ((wx, wy), color) in marks {
let px = wx * TILE_PX - cx;
+75 -15
View File
@@ -10,9 +10,21 @@ const MAX_EXPANSIONS: usize = 4096;
/// Longest route we will plan. Keeps the reply small and bounds replan cost.
const MAX_ROUTE_LEN: usize = 64;
/// 4-connected A* from `start` to `goal` (manhattan heuristic — admissible on a
/// cardinal grid). Returns the tiles to walk, excluding `start`, ending on `goal`,
/// or `None` if the goal is unreachable within the search budget.
/// Step costs. In game terms every step costs the same (Chebyshev geometry) — the +1 on
/// diagonals is purely a tie-breaker so that among equally short paths the one with the
/// fewest diagonals wins (straight lines stay straight instead of zigzagging). Because a
/// route is at most `MAX_ROUTE_LEN` steps, the accumulated surcharge (≤ 64) can never
/// outweigh one extra step (100): the step count always stays Chebyshev-minimal.
const CARDINAL_COST: i32 = 100;
const DIAGONAL_COST: i32 = 101;
/// 8-connected A* from `start` to `goal`. World geometry is chessboard (Chebyshev):
/// every step is one game turn, so routes are minimal in step count; the tiny diagonal
/// surcharge (see `DIAGONAL_COST`) only breaks ties among equally short paths. Each
/// expansion goes through the shared `step_allowed` rule — so the plan can never contain
/// a step the server would refuse (including corner cutting). Returns the tiles to walk,
/// excluding `start`, ending on `goal`, or `None` if the goal is unreachable within the
/// search budget.
pub fn find_path(
start: (i32, i32),
goal: (i32, i32),
@@ -22,7 +34,13 @@ pub fn find_path(
return None;
}
let h = |p: (i32, i32)| (p.0 - goal.0).abs() + (p.1 - goal.1).abs();
// Exact open-field cost: `max` steps, of which `min` must be diagonal — admissible
// and consistent, since obstacles can only make a path more expensive.
let h = |p: (i32, i32)| {
let dx = (p.0 - goal.0).abs();
let dy = (p.1 - goal.1).abs();
CARDINAL_COST * dx.max(dy) + (DIAGONAL_COST - CARDINAL_COST) * dx.min(dy)
};
// (f, tile) min-heap; g and parent per visited tile.
let mut open = BinaryHeap::new();
@@ -52,12 +70,15 @@ pub fn find_path(
}
let g = best[&cur].0;
for (dx, dy) in [(0, -1), (0, 1), (-1, 0), (1, 0)] {
for (dx, dy) in [
(0, -1), (0, 1), (-1, 0), (1, 0),
(1, -1), (1, 1), (-1, 1), (-1, -1),
] {
let next = (cur.0 + dx, cur.1 + dy);
if blocked(next.0, next.1) {
if !shared::step_allowed(cur, next, &blocked) {
continue;
}
let ng = g + 1;
let ng = g + if dx != 0 && dy != 0 { DIAGONAL_COST } else { CARDINAL_COST };
if best.get(&next).is_none_or(|&(og, _)| ng < og) {
best.insert(next, (ng, cur));
open.push(Reverse((ng + h(next), next)));
@@ -71,29 +92,68 @@ pub fn find_path(
mod tests {
use super::find_path;
/// Every consecutive pair must be a legal step under the shared movement rule.
fn assert_valid(start: (i32, i32), path: &[(i32, i32)], blocked: impl Fn(i32, i32) -> bool) {
let mut from = start;
for &to in path {
assert!(shared::step_allowed(from, to, &blocked), "illegal step {from:?} → {to:?}");
from = to;
}
}
#[test]
fn straight_line() {
fn straight_line_stays_straight() {
// The diagonal tie-breaker rules out equal-length zigzags.
let path = find_path((0, 0), (3, 0), |_, _| false).unwrap();
assert_eq!(path, vec![(1, 0), (2, 0), (3, 0)]);
}
#[test]
fn diagonal_is_one_step_per_tile() {
let path = find_path((0, 0), (3, 3), |_, _| false).unwrap();
assert_eq!(path, vec![(1, 1), (2, 2), (3, 3)]); // not an L of length 6
}
#[test]
fn mixed_route_is_step_minimal() {
// (5,2): 5 steps — 2 diagonal, 3 cardinal, never more.
let path = find_path((0, 0), (5, 2), |_, _| false).unwrap();
assert_eq!(path.len(), 5);
assert_eq!(path.last(), Some(&(5, 2)));
let diagonals = std::iter::once((0, 0)).chain(path.iter().copied())
.zip(path.iter().copied())
.filter(|(a, b)| a.0 != b.0 && a.1 != b.1)
.count();
assert_eq!(diagonals, 2);
assert_valid((0, 0), &path, |_, _| false);
}
#[test]
fn detours_around_wall() {
// Vertical wall at x=2 with a gap at y=5.
// Vertical wall at x=2 with a gap at y=5. The corner rule forces the gap to be
// entered and left orthogonally: 5 steps down to (1,5), through (2,5) and (3,5),
// 5 steps back up to (4,0).
let blocked = |x: i32, y: i32| x == 2 && y != 5;
let path = find_path((0, 0), (4, 0), blocked).unwrap();
assert_eq!(path.last(), Some(&(4, 0)));
assert!(path.iter().all(|&(x, y)| !blocked(x, y)));
assert!(path.windows(2).all(|w| {
(w[1].0 - w[0].0).abs() + (w[1].1 - w[0].1).abs() == 1
}));
assert!(path.contains(&(2, 5)));
assert_eq!(path.len(), 14); // 4 across + 2·5 detour
assert_valid((0, 0), &path, blocked);
assert_eq!(path.len(), 12);
}
#[test]
fn no_corner_cutting() {
// Blockers at (1,0) and (0,1) touch diagonally; the direct king move
// (0,0) → (1,1) must not squeeze between them.
let blocked = |x: i32, y: i32| (x, y) == (1, 0) || (x, y) == (0, 1);
let path = find_path((0, 0), (1, 1), blocked).unwrap();
assert_valid((0, 0), &path, blocked);
assert_eq!(path.len(), 6); // shortest legal detour around either blocker
}
#[test]
fn unreachable_is_none() {
// Goal sealed in by a ring.
// Goal sealed in by a ring (Chebyshev radius 1 — also seals diagonals).
let blocked = |x: i32, y: i32| (x - 10).abs().max((y - 10).abs()) == 1;
assert_eq!(find_path((0, 0), (10, 10), blocked), None);
}
+12
View File
@@ -44,6 +44,7 @@ pub struct InputState {
/// Cursor position in framebuffer pixels (pbio delivers framebuffer coordinates).
mouse_pos: (i32, i32),
mouse_clicked: bool,
mouse_held: bool,
}
impl InputState {
@@ -54,6 +55,7 @@ impl InputState {
released: Vec::new(),
mouse_pos: (-1, -1),
mouse_clicked: false,
mouse_held: false,
}
}
@@ -63,6 +65,11 @@ impl InputState {
pub fn push_click(&mut self) {
self.mouse_clicked = true;
self.mouse_held = true;
}
pub fn release_click(&mut self) {
self.mouse_held = false;
}
pub fn mouse_pos(&self) -> (i32, i32) {
@@ -74,6 +81,11 @@ impl InputState {
self.mouse_clicked
}
/// True while the left button is down. Like `held` keys, this survives `clear()`.
pub fn mouse_held(&self) -> bool {
self.mouse_held
}
pub fn push(&mut self, action: GameAction) {
self.pressed.push(action);
if !self.held.contains(&action) {
+9 -1
View File
@@ -21,7 +21,12 @@ fn main() {
let input_map = input::InputMap::new();
let mut input_state = input::InputState::new();
let mut last_update = Instant::now();
let mut game = game::Game::start();
// Optional server address (e.g. netsim proxy): `client 127.0.0.1:7778`.
let server_addr = std::env::args().nth(1)
.unwrap_or_else(|| "127.0.0.1:7777".into())
.parse()
.expect("bad server address");
let mut game = game::Game::start(server_addr);
while !plat.should_close() {
plat.poll_events(Some(Duration::from_millis(20)));
@@ -45,6 +50,9 @@ fn main() {
Event::MouseBtn { button: MouseButton::Left, pressed: true } => {
input_state.push_click();
}
Event::MouseBtn { button: MouseButton::Left, pressed: false } => {
input_state.release_click();
}
Event::CloseRequested => plat.request_close(),
_ => {}
}
+11 -8
View File
@@ -26,7 +26,10 @@ pub enum NetEvent {
Pong { rtt_ms: u64 },
State { tick: u32, player_entity_id: u32 },
Chunk { chunk_id: u32 },
Entity { entities: Vec<EntityInfo> },
/// An authoritative entity snapshot taken at server tick `tick` — the tick doubles
/// as the acknowledgment cursor for tick-addressed actions: every movement window
/// at or before it has been consumed.
Entity { tick: u32, entities: Vec<EntityInfo> },
Disconnected,
}
@@ -36,7 +39,6 @@ const FNV_PRIME: u32 = 16_777_619;
pub struct NetClient {
socket: UdpSocket,
ping_sent_at: Option<Instant>,
action_sequence: u32,
last_action_sent: Instant,
last_state_received: Option<Instant>,
pub chunk_cache: HashMap<u32, CachedChunk>,
@@ -55,7 +57,6 @@ impl NetClient {
Self {
socket,
ping_sent_at: None,
action_sequence: 0,
last_action_sent: Instant::now(),
last_state_received: None,
chunk_cache: HashMap::new(),
@@ -67,12 +68,13 @@ impl NetClient {
}
}
pub fn send_action(&mut self, player_action: u16) {
self.action_sequence += 1;
/// Send an action addressed to a server tick (see `ActionPacket::target_tick`).
/// `target_tick == 0` is a pure keep-alive / cache-ack with no scheduling intent.
pub fn send_action(&mut self, player_action: u16, target_tick: u32) {
let packet = ActionPacket {
header: Header::new(packet_type::ACTION),
auth_token: 0,
sequence: self.action_sequence,
target_tick,
cache: {
let mut cache = [ChunkEntry { chunk_id: 0, version: 0 }; 9];
if let Some(slots) = self.last_state_chunks {
@@ -102,7 +104,7 @@ impl NetClient {
const TIMEOUT: Duration = Duration::from_secs(10);
if self.last_action_sent.elapsed() >= KEEPALIVE {
self.send_action(shared::player_action::NOOP);
self.send_action(shared::player_action::NOOP, 0);
}
let mut events = Vec::new();
@@ -157,9 +159,10 @@ impl NetClient {
if pkt.packet_flags & 1 == 0 {
// last datagram in sequence
if self.entity_checksum_accum != self.last_entity_checksum {
self.send_action(shared::player_action::NOOP);
self.send_action(shared::player_action::NOOP, 0);
}
events.push(NetEvent::Entity {
tick: pkt.tick,
entities: std::mem::take(&mut self.pending_entities),
});
}
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "netsim"
version = "0.1.0"
edition = "2024"
[dependencies]
+170
View File
@@ -0,0 +1,170 @@
//! Bad-internet simulator: a UDP proxy between game client and server that adds
//! delay, jitter and packet loss, per direction. Dev tool only — the game itself
//! never knows it exists.
//!
//! Usage:
//! cargo run -p netsim -- [--listen 7778] [--upstream 127.0.0.1:7777]
//! [--delay MS] [--jitter MS] [--loss PERCENT]
//! [--up-delay MS] [--up-jitter MS] [--up-loss PERCENT]
//! [--down-delay MS] [--down-jitter MS] [--down-loss PERCENT]
//!
//! `--delay/--jitter/--loss` set both directions; the `--up-*` (client → server) and
//! `--down-*` (server → client) variants override one direction. Jitter is uniform in
//! ±MS around the delay; reordering emerges from jitter naturally, as on a real link.
//! Point the client at the listen port (e.g. `client 127.0.0.1:7778`).
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::net::{SocketAddr, UdpSocket};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[derive(Clone, Copy)]
struct LinkParams {
delay_ms: f32,
jitter_ms: f32,
loss_pct: f32,
}
/// Xorshift64* — plenty for impairment dice; avoids pulling in a rand dependency.
struct Rng(u64);
impl Rng {
fn new() -> Self {
let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64;
Rng(seed | 1)
}
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// Uniform in [0, 1).
fn unit(&mut self) -> f32 {
(self.next() >> 40) as f32 / (1u64 << 24) as f32
}
}
impl LinkParams {
/// Roll the dice for one datagram: `None` = lost, `Some(d)` = deliver after `d`.
fn impair(&self, rng: &mut Rng) -> Option<Duration> {
if rng.unit() * 100.0 < self.loss_pct {
return None;
}
let jitter = (rng.unit() * 2.0 - 1.0) * self.jitter_ms;
Some(Duration::from_secs_f32((self.delay_ms + jitter).max(0.0) / 1000.0))
}
}
/// A datagram waiting for its delivery time. `Up` = client → server.
enum Dir {
Up,
Down,
}
struct Pending {
due: Instant,
dir: Dir,
data: Vec<u8>,
}
// BinaryHeap ordering: only `due` matters (earliest first via Reverse).
impl PartialEq for Pending {
fn eq(&self, other: &Self) -> bool { self.due == other.due }
}
impl Eq for Pending {}
impl PartialOrd for Pending {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { Some(self.cmp(other)) }
}
impl Ord for Pending {
fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.due.cmp(&other.due) }
}
fn parse_args() -> (u16, SocketAddr, LinkParams, LinkParams) {
let mut listen: u16 = 7778;
let mut upstream: SocketAddr = "127.0.0.1:7777".parse().unwrap();
let mut both = LinkParams { delay_ms: 0.0, jitter_ms: 0.0, loss_pct: 0.0 };
let mut up_over = [None::<f32>; 3]; // delay, jitter, loss
let mut down_over = [None::<f32>; 3];
let mut args = std::env::args().skip(1);
while let Some(flag) = args.next() {
let val = args.next().unwrap_or_else(|| panic!("missing value for {flag}"));
let num = || val.parse::<f32>().unwrap_or_else(|_| panic!("bad number for {flag}: {val}"));
match flag.as_str() {
"--listen" => listen = val.parse().expect("bad listen port"),
"--upstream" => upstream = val.parse().expect("bad upstream address"),
"--delay" => both.delay_ms = num(),
"--jitter" => both.jitter_ms = num(),
"--loss" => both.loss_pct = num(),
"--up-delay" => up_over[0] = Some(num()),
"--up-jitter" => up_over[1] = Some(num()),
"--up-loss" => up_over[2] = Some(num()),
"--down-delay" => down_over[0] = Some(num()),
"--down-jitter" => down_over[1] = Some(num()),
"--down-loss" => down_over[2] = Some(num()),
_ => panic!("unknown flag {flag}"),
}
}
let apply = |over: [Option<f32>; 3]| LinkParams {
delay_ms: over[0].unwrap_or(both.delay_ms),
jitter_ms: over[1].unwrap_or(both.jitter_ms),
loss_pct: over[2].unwrap_or(both.loss_pct),
};
(listen, upstream, apply(up_over), apply(down_over))
}
fn main() {
let (listen, upstream, up, down) = parse_args();
let client_sock = UdpSocket::bind(("127.0.0.1", listen)).expect("bind listen port");
let server_sock = UdpSocket::bind("127.0.0.1:0").expect("bind upstream socket");
server_sock.connect(upstream).expect("connect upstream");
client_sock.set_nonblocking(true).unwrap();
server_sock.set_nonblocking(true).unwrap();
println!("netsim: 127.0.0.1:{listen} ⇄ {upstream}");
println!(" up: {:.0} ms ±{:.0} ms, {:.1}% loss", up.delay_ms, up.jitter_ms, up.loss_pct);
println!(" down: {:.0} ms ±{:.0} ms, {:.1}% loss", down.delay_ms, down.jitter_ms, down.loss_pct);
let mut rng = Rng::new();
let mut queue: BinaryHeap<Reverse<Pending>> = BinaryHeap::new();
// The one game client, learned from its first datagram (re-learned on address change).
let mut client_addr: Option<SocketAddr> = None;
let mut buf = [0u8; 2048];
loop {
let now = Instant::now();
while let Ok((n, from)) = client_sock.recv_from(&mut buf) {
client_addr = Some(from);
if let Some(d) = up.impair(&mut rng) {
queue.push(Reverse(Pending { due: now + d, dir: Dir::Up, data: buf[..n].to_vec() }));
}
}
while let Ok(n) = server_sock.recv(&mut buf) {
if let Some(d) = down.impair(&mut rng) {
queue.push(Reverse(Pending { due: now + d, dir: Dir::Down, data: buf[..n].to_vec() }));
}
}
while queue.peek().is_some_and(|Reverse(p)| p.due <= now) {
let Reverse(p) = queue.pop().unwrap();
match p.dir {
Dir::Up => { server_sock.send(&p.data).ok(); }
Dir::Down => {
if let Some(addr) = client_addr {
client_sock.send_to(&p.data, addr).ok();
}
}
}
}
std::thread::sleep(Duration::from_millis(1));
}
}
+17 -3
View File
@@ -58,7 +58,7 @@ Currently carries the chunk manifest for the 3×3 neighbourhood around the playe
The `entity_checksum` lets the client detect a lost `EntityPacket` without a dedicated
ACK: if the checksum differs from the one computed over the last received entity update,
the client knows to retransmit an `ActionPacket` (sequence preserved, no-op action) to
the client knows to retransmit an `ActionPacket` (`target_tick = 0`, no-op action) to
prompt the server to re-send the current entity state.
---
@@ -71,13 +71,27 @@ Sent by the client on player action or on a chunk cache miss.
|--------|------|--------------|-----------------|--------------------------------|
| 0 | 6 | Header | `header` | packet_type = 1 |
| 6 | 8 | u64 | `auth_token` | Token of the current session |
| 14 | 4 | u32 | `sequence` | Monotonically increasing |
| 14 | 4 | u32 | `target_tick` | Tick the action is scheduled for (see below); 0 = keep-alive/ack only |
| 18 | 54 | ChunkEntry | `cache[9]` | Versions client currently holds |
| 72 | 2 | PlayerAction | `player_action` | Derived from user input |
| 74 | ? | ActionData | `action_data` | Dependent on PlayerAction |
**Minimum: 74 bytes** (no ActionData)
**Tick-addressed scheduling.** Actions are scheduled onto the server's tick timeline
instead of being consumed in arrival order. `target_tick` selects the movement window
(`target_tick / TICKS_PER_MOVE`, rounded up) the action executes in:
- A second action addressed to the same window **replaces** the first — this is how the
client retracts (NOOP) or changes a scheduled step until its window executes, and how
retransmits dedupe for free.
- A **late** action (window already passed on arrival) moves to the next window, but
only if that slot is empty: late actions fill gaps, they never override newer intent.
- Only the next `ACTION_WINDOW_HORIZON` windows are addressable; anything beyond is
dropped. Combined with one-action-per-window execution this bounds server memory and
movement speed regardless of client behavior.
- `target_tick = 0` carries no scheduling intent (keep-alive / cache-ack packets).
**ChunkEntry (6 bytes)**
| Offset | Size | Type | Field |
@@ -302,7 +316,7 @@ whatever is still missing. No deduplication logic is required.
the client simply waits one tick (~100 ms).
**Lost EntityPacket** — detected via the `entity_checksum` in the next `StatePacket`.
The client retransmits a no-op `ActionPacket` (same sequence number, no-op action,
The client retransmits a no-op `ActionPacket` (`target_tick = 0`, no-op action,
current cache state); the server treats this as a normal diff request and re-sends the
full entity update for the tick.
+92 -9
View File
@@ -37,10 +37,93 @@ terminal). The platform layer — window, GPU, input, RGB332 palette — lives i
(currently empty — every tile walkable) keeps client prediction and server sim in lockstep.
- [x] Click-to-move: framebuffer click → world tile, A* over the chunk cache
(`client/src/game/pathfind.rs`, unknown chunks count as blocked), route translated into
cardinal actions one step per movement interval — the server only ever sees N/E/S/W and
stays authoritative. Keyboard input cancels the route; each step is re-validated at send
time and a blocked step voids the route. In-flight steps render bright blue, planned
route dim blue.
cardinal actions one step per movement interval — the server only ever sees movement
actions and stays authoritative. Keyboard input cancels the route; each step is
re-validated at send time and a blocked step voids the route. In-flight steps render
bright blue, planned route dim blue. Click-and-hold steers continuously: while the
button is held the route keeps replanning toward the tile under the cursor (only
when that tile changes — cursor or camera movement), sweeping across blocked tiles
keeps the current route, and a voided route replans automatically while held.
- [x] 8-directional movement in chessboard geometry: world physics use the Chebyshev
metric — diagonal and cardinal steps are the same distance, a "circle" is a square
of tiles, matching the square viewport. Four diagonal actions in
`shared::player_action`; the single-step rule lives in `shared::step_allowed`
(king move onto a free tile, diagonals additionally need both orthogonal neighbors
free — no corner cutting) and is the one function used by the server sim, client
send-time validation and the client A* (8-connected, Chebyshev heuristic). Two held
keys walk diagonally.
- [x] Collision vocabulary seeded with test values (`shared::tile_collidable`): id 146
(trees/rocks) and id 0 — id 0 doubles as the server's invisible world border, which
the client previously mispredicted as walkable. A proper tile-data file format
replaces this table later.
- [x] Tick-addressed action scheduling (supersedes two interim designs — a sequence-
deduped FIFO queue and its flow control — that fixed a periodic walking hitch and
a path/route deadlock but kept two free-running clocks racing each other). Actions
are now scheduled onto the server's tick timeline: `ActionPacket.target_tick`
(formerly `sequence`) selects the movement window, a second action to the same
window *replaces* the first (retraction via NOOP, rescheduling, retransmit dedup),
late actions fill only an *empty* next window (gap-filling without overriding
newer intent — needed over real internet links so actions don't die pointlessly),
and only `ACTION_WINDOW_HORIZON = 3` future windows are addressable. The sim keeps
per-entity window slot maps and executes at most one action per window, so floods
can neither grow memory nor speed anyone up. The client estimates the server tick
from `StatePacket.tick` plus elapsed time and schedules each step into the next
window — one send per window by construction, no local send timer, no clock-rate
race. `target_tick = 0` marks keep-alive/cache-ack packets with no scheduling
intent. See `notes/protocol.md` (ActionPacket).
- [x] Client-side unexpected-state handling: the movement goal is persistent (outlives
the planned route) and every surprise reroutes toward it — a blocked route step
replans instead of voiding the plan, and a confirmed position off the predicted
path (lost/rejected/overridden step) retracts all still-scheduled steps (NOOP to
their windows), drops the stale prediction and replans from the confirmed tile.
Changing plans mid-run (new click/steer target) likewise retracts scheduled-but-
unexecuted steps, so old intent stops playing out on the server within a window.
The goal is released on arrival, unreachability, keyboard override, or a discrete
click on an unreachable tile. This is the "reactive replanning" item formerly
parked under Later — Robustness.
- [x] `netsim` — bad-internet simulator (workspace member, dev tool): a UDP proxy adding
delay, jitter and loss per direction (`--delay/--jitter/--loss`, `--up-*`/`--down-*`
overrides; reordering emerges from jitter). No root, game-traffic only, zero deps.
The client takes an optional server address argument to point at it:
`cargo run -p netsim -- --delay 80 --jitter 30 --loss 5` + `client 127.0.0.1:7778`.
- [x] Bad-link hardening (netsim immediately broke the naive scheduling — locks under
isolated loss 15% and isolated delay 120 ms):
(a) RTT-adaptive scheduling lead — the client pings automatically (1 Hz, smoothed),
and schedules `ceil((rtt + margin) / window)` windows ahead instead of always one:
under systematic latency "late" had been the *normal* case. The retraction horizon
moves out the same way (a cancellation needs the same lead an action does).
(b) Late-rule cleanup in the sim — a late movement action keeps its *order*, not
its time (first still-empty upcoming window), so bunched late arrivals no longer
collapse onto one slot and eat each other; late NOOPs are dropped outright (as
gap-fillers they used to block real steps: retract → late NOOPs poison upcoming
windows → replanned steps eaten → retract again — a lock loop).
(c) Client stall watchdog — steps in flight but nothing confirmed for 750 ms means
the prediction is dead no matter why (e.g. *all* in-flight actions lost: the server
never moves, so the moved-off-plan desync detection never fires, path stays full,
nothing is ever sent again): retract, drop, replan toward the goal.
`PATH_MAX_LEN` is back to 8 as a pure prediction bound — server safety now comes
from window addressing, and on a slow link several correct steps are legitimately
unconfirmed at once (confirmations lag a full RTT).
- [x] Prediction rebuilt as predict → ack → replay (replaces the per-problem patches
above with structural robustness; fixed multi-second replan storms at high ping).
The old model stored absolute predicted tiles and reconciled by tile matching, so
any surprise "invalidated" the whole prediction and recovery meant clear + replan
from the confirmed position — but steps inside the retraction horizon cannot be
cancelled and still execute ("zombies"), shifting the server off every fresh plan
and re-triggering recovery in a loop. Now: pending steps are `(window, delta)`
pairs, `EntityPacket.tick` is the acknowledgment cursor (every window ≤ tick/4 is
provably consumed — executed, rejected or lost, it no longer matters which), and
the predicted position is always *derived* by replaying pending deltas on top of
the confirmed position. A surprise shifts the prediction instead of killing it; a
route that no longer connects triggers one clean replan toward the persistent goal
via the existing send-time validation. Deleted outright: tile-matching reconcile,
the moved-off-plan desync heuristic, the stall watchdog, and the idle path drop —
acked windows expire pending steps automatically, so the path cannot go stale.
The replay applies each pending delta through the shared `step_allowed` rule —
exactly as the server will — so a delta the server is going to reject does not
move the prediction either, and the predicted position can never sit inside a
wall (previously a diverged prediction could, causing a brief walk-into-wall
lock until the acks caught up).
---
@@ -65,8 +148,9 @@ This supersedes the original 20/10/1 Hz three-tier sketch.
Remaining follow-ups when the need is concrete:
- Collision vocabulary: `tile_flags` in `map.rs` is currently empty (all walkable). Fill in
collidable / opaque tile ids as the level design requires.
- Collision vocabulary: seeded with test values (0, 146) in `shared::tile_collidable`.
Decide on a proper file format for tile data (collision, opacity, …) instead of a
hardcoded match, then feed both server and client from it.
- Tile flipping: orientation is discarded on load; revisit with the sprite pass (09).
- Multiple / object layers (spawns, triggers) — not yet parsed.
@@ -119,10 +203,9 @@ Sprite rendering already landed (see Achieved). Camera status:
### Later — Robustness + auth
- Reactive pathfinding: replan from the confirmed position toward the original goal when a
route step is blocked or the server diverges from the prediction (currently the route is
simply voided)
- Auth token handshake: replace source-address identity (`auth_token` field exists but is unused)
- Per-address rate limiting for the action ingest (only meaningful once identity is real —
address spoofing bypasses any limit before then)
- Multi-datagram `EntityPacket`: server currently truncates at 66 entities (`net.rs` TODO)
- Asset embedding: `include_bytes!` for single-binary distribution
+23 -8
View File
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::mem::size_of;
use std::net::{SocketAddr, UdpSocket};
use std::sync::mpsc::{self, Receiver, SyncSender};
@@ -18,10 +18,18 @@ pub struct NetClient {
pub entity_id: u32,
pub last_seen: Instant,
pub send_phase: u8,
pub pending_action: Option<u16>,
/// Tick-addressed actions received since the last drain, in arrival order —
/// `(target_tick, action)`. Window resolution, replacement and the lateness rule
/// all live in the sim; this is only the per-tick hand-off buffer, bounded so a
/// flood between drains cannot grow it.
pub pending_actions: VecDeque<(u32, u16)>,
pub last_cache: [ChunkEntry; 9],
}
/// Hand-off buffer bound. Drained every tick; an honest client sends at most one
/// action per movement window, so a handful is already generous.
const PENDING_ACTIONS_MAX: usize = 8;
pub struct Net {
clients: HashMap<SocketAddr, NetClient>,
new_addrs: Vec<SocketAddr>,
@@ -72,7 +80,7 @@ impl Net {
entity_id,
last_seen: Instant::now(),
send_phase: (entity_id % 2) as u8,
pending_action: None,
pending_actions: VecDeque::new(),
last_cache: [ChunkEntry { chunk_id: 0, version: 0 }; 9],
});
}
@@ -98,9 +106,15 @@ impl Net {
if header.packet_type == packet_type::ACTION && data.len() == size_of::<ActionPacket>() {
let pkt: ActionPacket = bytemuck::pod_read_unaligned(&data);
if let Some(client) = self.clients.get_mut(&addr) {
client.pending_action = Some(pkt.player_action);
client.last_seen = Instant::now();
client.last_cache = pkt.cache;
// `target_tick == 0` marks pure keep-alive / cache-ack packets — they
// must never enter the schedule. Everything else (including NOOPs, which
// *retract* the addressed window) is handed to the sim in arrival order.
let target_tick = pkt.target_tick;
if target_tick != 0 && client.pending_actions.len() < PENDING_ACTIONS_MAX {
client.pending_actions.push_back((target_tick, pkt.player_action));
}
} else if !self.new_addrs.contains(&addr) {
self.new_addrs.push(addr);
}
@@ -122,11 +136,12 @@ impl Net {
self.clients.retain(|_, c| c.last_seen.elapsed().as_secs() < 10);
}
pub fn drain_actions(&mut self) -> Vec<(u32, u16)> {
pub fn drain_actions(&mut self) -> Vec<(u32, u32, u16)> {
self.clients.values_mut()
.filter_map(|c| {
let action = c.pending_action.take()?;
Some((c.entity_id, action))
.flat_map(|c| {
let entity_id = c.entity_id;
c.pending_actions.drain(..)
.map(move |(target_tick, action)| (entity_id, target_tick, action))
})
.collect()
}
+61 -23
View File
@@ -1,10 +1,16 @@
use std::collections::HashMap;
use shared::player_action;
use std::collections::{BTreeMap, HashMap};
use shared::{ACTION_WINDOW_HORIZON, TICKS_PER_MOVE};
use crate::world::World;
pub struct Sim {
pub world: World,
pending: HashMap<u32, u16>,
/// Scheduled actions per entity, keyed by movement window (`tick / TICKS_PER_MOVE`).
/// The window slot is the whole ordering model: an action addressed to an occupied
/// window *replaces* it (retraction and rescheduling by the client, dedup of
/// retransmits), a late action only fills the next window if it is empty, and
/// anything past `ACTION_WINDOW_HORIZON` is dropped — so no client can grow server
/// memory or move faster than one action per window.
pending: HashMap<u32, BTreeMap<u32, u16>>,
}
impl Sim {
@@ -12,35 +18,67 @@ impl Sim {
Self { world, pending: HashMap::new() }
}
pub fn tick(&mut self, tick: u32, actions: &[(u32, u16)]) {
for &(entity_id, action) in actions {
self.pending.insert(entity_id, action);
pub fn tick(&mut self, tick: u32, actions: &[(u32, u32, u16)]) {
// The earliest window still addressable at this tick. On a movement tick that is
// the window executing *this call* — actions arriving the same tick still count.
let min_window = tick.div_ceil(TICKS_PER_MOVE);
for &(entity_id, target_tick, action) in actions {
let slots = self.pending.entry(entity_id).or_default();
let window = target_tick.div_ceil(TICKS_PER_MOVE);
if window < min_window {
// Late. A movement action keeps its *order* instead of its time: it
// fills the first still-empty upcoming window, so bunched late arrivals
// don't collapse onto one slot and eat each other. A late NOOP is
// dropped — once its window has passed, its cancellation intent is
// ambiguous, and as a gap-filler it would block real steps (worst case
// one stale step executes; the client's reconciliation handles that).
if shared::action_delta(action).is_some() {
let gap = (min_window..min_window + ACTION_WINDOW_HORIZON as u32)
.find(|w| !slots.contains_key(w));
if let Some(w) = gap {
slots.insert(w, action);
}
if tick.is_multiple_of(4) {
let pending = std::mem::take(&mut self.pending);
self.entity_tick(&pending);
}
} else if window - min_window < ACTION_WINDOW_HORIZON as u32 {
slots.insert(window, action); // newest addressing wins
}
}
fn entity_tick(&mut self, actions: &HashMap<u32, u16>) {
for (&entity_id, &action) in actions {
if action == player_action::NOOP {
continue;
if tick.is_multiple_of(TICKS_PER_MOVE) {
self.entity_tick(tick / TICKS_PER_MOVE);
}
}
fn entity_tick(&mut self, window: u32) {
// Exactly one action per entity per movement window, then the window is gone.
let actions: Vec<(u32, u16)> = self.pending.iter_mut()
.filter_map(|(&id, slots)| {
let action = slots.remove(&window);
slots.retain(|&w, _| w > window); // drop anything the timeline passed by
action.map(|a| (id, a))
})
.collect();
self.pending.retain(|_, slots| !slots.is_empty());
for (entity_id, action) in actions {
let delta = match shared::action_delta(action) {
Some(d) => d,
None => continue, // NOOP or garbage
};
let pos = match self.world.entities.get(&entity_id) {
Some(e) => e.pos,
None => continue,
};
let (nx, ny) = match action {
player_action::NORTH => (pos.0, pos.1 - 1),
player_action::EAST => (pos.0 + 1, pos.1),
player_action::SOUTH => (pos.0, pos.1 + 1),
player_action::WEST => (pos.0 - 1, pos.1),
_ => continue,
};
let blocked = self.world.tile_flags(nx, ny).map_or(true, |f| f.collidable());
if !blocked {
self.world.move_entity(entity_id, (nx, ny));
let from = (pos.0 as i32, pos.1 as i32);
let to = (from.0 + delta.0, from.1 + delta.1);
// `step_allowed` is the shared movement rule (chessboard geometry, no corner
// cutting) — the client predicts with the exact same function.
let allowed = shared::step_allowed(from, to, |x, y| {
self.world.tile_flags(x as i16, y as i16).map_or(true, |f| f.collidable())
});
if allowed {
self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16));
}
}
}
+80 -3
View File
@@ -6,8 +6,77 @@ pub mod player_action {
pub const EAST: u16 = 2;
pub const SOUTH: u16 = 3;
pub const WEST: u16 = 4;
pub const NORTH_EAST: u16 = 5;
pub const SOUTH_EAST: u16 = 6;
pub const SOUTH_WEST: u16 = 7;
pub const NORTH_WEST: u16 = 8;
}
/// The movement delta of an action, or `None` for `NOOP` and unknown values.
pub fn action_delta(action: u16) -> Option<(i32, i32)> {
match action {
player_action::NORTH => Some((0, -1)),
player_action::EAST => Some((1, 0)),
player_action::SOUTH => Some((0, 1)),
player_action::WEST => Some((-1, 0)),
player_action::NORTH_EAST => Some((1, -1)),
player_action::SOUTH_EAST => Some((1, 1)),
player_action::SOUTH_WEST => Some((-1, 1)),
player_action::NORTH_WEST => Some((-1, -1)),
_ => None,
}
}
/// The action for a single-step delta. Inverse of [`action_delta`]; panics on anything
/// that is not a king move.
pub fn delta_action(dx: i32, dy: i32) -> u16 {
match (dx, dy) {
(0, -1) => player_action::NORTH,
(1, 0) => player_action::EAST,
(0, 1) => player_action::SOUTH,
(-1, 0) => player_action::WEST,
(1, -1) => player_action::NORTH_EAST,
(1, 1) => player_action::SOUTH_EAST,
(-1, 1) => player_action::SOUTH_WEST,
(-1, -1) => player_action::NORTH_WEST,
d => panic!("non-step delta {d:?}"),
}
}
/// The single-step movement rule, shared by the server sim, client prediction and the
/// client pathfinder so all three agree on the same physics.
///
/// World geometry is chessboard (Chebyshev): diagonal and cardinal steps are the same
/// distance, so a step is any king move onto a free tile. A diagonal step additionally
/// requires *both* orthogonal neighbor tiles to be free — no squeezing between two
/// diagonally touching blockers (corner cutting).
pub fn step_allowed(
from: (i32, i32),
to: (i32, i32),
blocked: impl Fn(i32, i32) -> bool,
) -> bool {
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
if dx.abs() > 1 || dy.abs() > 1 || (dx == 0 && dy == 0) {
return false;
}
if blocked(to.0, to.1) {
return false;
}
dx == 0 || dy == 0 || (!blocked(from.0 + dx, from.1) && !blocked(from.0, from.1 + dy))
}
/// Server base tick rate. The shared timeline all action scheduling is expressed in.
pub const TICK_HZ: u32 = 24;
/// Movement resolves on every `TICKS_PER_MOVE`-th tick — one "movement window" per
/// `TICKS_PER_MOVE` ticks (6 Hz). Window `w` executes at tick `w * TICKS_PER_MOVE`.
pub const TICKS_PER_MOVE: u32 = 4;
/// How many *future* movement windows a client may address. Part of the protocol
/// contract: actions targeted beyond this horizon are dropped, and the client's
/// unconfirmed in-flight steps must stay within it.
pub const ACTION_WINDOW_HORIZON: usize = 3;
pub const MAGIC: u16 = 0x524C;
pub const VERSION: u8 = 1;
@@ -36,10 +105,12 @@ pub fn chunk_coords(id: u32) -> (i16, i16) {
///
/// Single source of truth for both the server (collision in the sim) and the client
/// (movement prediction). Keep this in lockstep with the tileset in `overworld.tga`.
/// Currently every authored tile is walkable.
///
/// Test values for now — a proper tile-data file format replaces this table later.
pub fn tile_collidable(tile_id: u16) -> bool {
match tile_id {
// e.g. 146 => true, // trees / rocks
0 => true, // empty / world border (the server pads chunks past the map rim with id 0)
146 => true, // trees / rocks
_ => false,
}
}
@@ -71,7 +142,13 @@ pub struct ChunkEntry {
pub struct ActionPacket {
pub header: Header,
pub auth_token: u64,
pub sequence: u32,
/// The server tick this action is scheduled for: it executes in the movement window
/// covering that tick, or — if it arrives late — in the next window *if that slot is
/// still empty* (late actions fill gaps, they never override newer intent). A second
/// action addressed to the same window replaces the first, so a scheduled step can be
/// retracted (NOOP) or changed until its window executes. `0` means "no scheduling
/// intent": pure keep-alive / cache-ack packets that must never touch the queue.
pub target_tick: u32,
/// Slot index = (dy+1)*3 + (dx+1), dx/dy ∈ {-1, 0, 1}.
/// Slot 4 is always the player's current chunk.
pub cache: [ChunkEntry; 9],