diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index af014be..06ecc04 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -18,7 +18,7 @@ pub mod world_server; use std::collections::{HashMap, HashSet}; use std::fs; use std::net::{Ipv4Addr, SocketAddr}; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::Context; use bevy_ecs::prelude::{Query, ResMut, Schedule, With, World}; @@ -32,6 +32,13 @@ use net::{NetworkServer, ServerEvent}; use player::{Player, Position, ViewDistance}; use world_server::{ServerWorld, cylinder_chunks}; +/// Nominal simulation rate, in ticks per second. Sole source of truth for both the loop's target period and the advisory rate advertised to clients in the handshake. +// TODO: make configurable through server configs once the real tick scheduler lands. +const TICK_RATE_HZ: u16 = 20; + +/// Target wall-clock period of one simulation tick, derived from [`TICK_RATE_HZ`]. +const TICK_PERIOD: Duration = Duration::from_millis(1000 / TICK_RATE_HZ as u64); + /// Streaming system: loads and unloads chunks so that the resident set matches the union of the cylinders around every player anchor. fn stream_chunks( anchors: Query<(&Position, &ViewDistance), With>, @@ -124,23 +131,25 @@ fn main() -> anyhow::Result<()> { // Spawn the networking thread and bind the QUIC endpoint. The synchronous simulation loop below communicates with it only by draining events. let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, net::DEFAULT_PORT)); - let (network, local_addr) = NetworkServer::spawn( - bind, - env!("CARGO_PKG_VERSION").to_owned(), - // Placeholder advisory tick rate. - 20, - ) - .context("spawning network server")?; + let (network, local_addr) = + NetworkServer::spawn(bind, env!("CARGO_PKG_VERSION").to_owned(), TICK_RATE_HZ) + .context("spawning network server")?; info!(%local_addr, "network endpoint listening"); + run_simulation(&mut world, &network) +} + +/// Runs the authoritative simulation loop forever, at the fixed cadence given by [`TICK_PERIOD`]. +fn run_simulation(world: &mut World, network: &NetworkServer) -> ! { // Chunk diffs are computed against an all-air baseline so each delivered payload is self-contained: the client renders only server-owned content and has no generator to reconstruct a worldgen baseline. Allocated once and shared across every delivery. let empty_baseline = Chunk::default(); // Per-connection streaming state, keyed by the stable session id the network thread assigns. let mut clients: HashMap = HashMap::new(); - // Authoritative simulation loop. loop { + let tick_start = Instant::now(); + // Fold network events into per-client subscription state. for event in network.poll_events() { match event { @@ -182,7 +191,17 @@ fn main() -> anyhow::Result<()> { } } - // Advisory ~20 Hz cadence until the real tick scheduler lands. - std::thread::sleep(Duration::from_millis(50)); + // Sleep only the unused remainder of the tick's budget, so the period stays [`TICK_PERIOD`] rather than growing with the cost of the work above. A tick that overruns its budget does not sleep at all; the overrun is reported because it is the signal that the server is falling behind its nominal rate. + let elapsed = tick_start.elapsed(); + if elapsed > TICK_PERIOD { + warn!( + elapsed_ms = elapsed.as_secs_f32() * 1000.0, + budget_ms = TICK_PERIOD.as_secs_f32() * 1000.0, + "tick overran its budget" + ); + } else { + // `saturating_sub` cannot underflow here (the branch already establishes `elapsed <= TICK_PERIOD`) and is used because `Duration` subtraction panics on overflow. + std::thread::sleep(TICK_PERIOD.saturating_sub(elapsed)); + } } }