use axum::{ extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, State, }, http::StatusCode, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; use futures_util::{SinkExt, StreamExt}; use rand::Rng; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, net::{IpAddr, Ipv4Addr, SocketAddr}, sync::Arc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use tokio::{ net::{TcpListener, UdpSocket}, sync::{mpsc, RwLock}, time, }; use tower_http::cors::CorsLayer; use uuid::Uuid; const HTTP_PORT: u16 = 47888; const DISCOVERY_PORT: u16 = 47889; const PROTOCOL_VERSION: u8 = 1; const MAGIC: &str = "JE_SKIN_LAN_GAME_V1"; const TICK_RATE: u64 = 30; const FIELD_HALF_W: f32 = 46.0; const FIELD_HALF_H: f32 = 62.0; const PADDLE_Y: f32 = -53.0; const PADDLE_W: f32 = 16.0; const PADDLE_H: f32 = 2.6; const PADDLE_SPEED: f32 = 74.0; const BALL_RADIUS: f32 = 1.45; const BASE_BALL_SPEED: f32 = 58.0; const BRICK_COLS: usize = 12; const BRICK_ROWS: usize = 7; const BRICK_W: f32 = 6.2; const BRICK_H: f32 = 2.9; const BRICK_GAP_X: f32 = 0.8; const BRICK_GAP_Y: f32 = 0.9; const BRICK_TOP: f32 = 44.0; #[derive(Clone)] struct AppState { inner: Arc>, lan_ip: IpAddr, } struct Inner { room: Option, discovered: HashMap, } struct Room { room_id: String, pairing_code: String, players: HashMap, tickets: HashMap, phase: GamePhase, game: GameState, } struct Player { id: String, name: String, role: PlayerRole, ready: bool, wants_restart: bool, connected: bool, tx: Option>, input: PlayerInput, prev_pause: bool, paddle_x: f32, score: i32, lives: i32, } #[derive(Default, Clone)] struct PlayerInput { axis: f32, launch: bool, pause: bool, } struct GameState { tick: u64, ball_x: f32, ball_y: f32, ball_vx: f32, ball_vy: f32, ball_launched: bool, bricks: Vec, last_pause_toggle: Instant, } #[derive(Clone, Copy, PartialEq, Eq)] enum GamePhase { Lobby, Running, Paused, Finished, } #[derive(Clone)] struct DiscoveredRoom { room: LanRoom, http_url: String, seen_at: Instant, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LanRoom { room_id: String, pairing_code: String, host_name: String, address: String, port: u16, players: usize, max_players: usize, protocol_version: u8, app_version: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] enum PlayerRole { Host, Guest, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CreateRoomRequest { player_name: String, protocol_version: u8, } #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct JoinRoomRequest { pairing_code: String, player_name: String, protocol_version: u8, } #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct JoinRoomResponse { room_id: String, player_id: String, ticket: String, ws_url: String, pairing_code: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct RoomsResponse { rooms: Vec, } #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Beacon { magic: String, protocol_version: u8, room_id: String, pairing_code: String, host_name: String, http_url: String, players: usize, max_players: usize, app_version: String, } #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] enum ClientMessage { Hello { protocol_version: u8, room_id: String, player_id: String, ticket: String, player_name: String, }, Ready { ready: bool, }, Restart, Input { seq: u64, client_time: f64, axis: f32, launch: bool, pause: bool, }, Ping { client_time: f64, }, } #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] enum ServerMessage { Welcome { room_id: String, player_id: String, role: PlayerRole, tick_rate: u32, seed: u64, }, Lobby { players: Vec, }, Snapshot { tick: u64, server_time: f64, phase: String, players: Vec, ball: BallState, bricks: String, }, Pong { client_time: f64, server_time: f64, }, Error { code: String, message: String, }, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct LobbyPlayer { id: String, name: String, ready: bool, role: PlayerRole, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct SnapshotPlayer { id: String, name: String, role: PlayerRole, ready: bool, paddle_x: f32, score: i32, lives: i32, } #[derive(Debug, Clone, Serialize)] struct BallState { x: f32, y: f32, vx: f32, vy: f32, } #[derive(Serialize)] struct ErrorBody { message: String, } struct ApiError { status: StatusCode, message: String, } impl ApiError { fn bad_request(message: impl Into) -> Self { Self { status: StatusCode::BAD_REQUEST, message: message.into(), } } fn not_found(message: impl Into) -> Self { Self { status: StatusCode::NOT_FOUND, message: message.into(), } } fn upstream(message: impl Into) -> Self { Self { status: StatusCode::BAD_GATEWAY, message: message.into(), } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { ( self.status, Json(ErrorBody { message: self.message, }), ) .into_response() } } pub async fn serve() -> anyhow::Result<()> { let state = AppState { inner: Arc::new(RwLock::new(Inner { room: None, discovered: HashMap::new(), })), lan_ip: guess_lan_ip(), }; tokio::spawn(discovery_recv(state.clone())); tokio::spawn(discovery_broadcast(state.clone())); tokio::spawn(game_loop(state.clone())); let app = Router::new() .route("/lan/rooms", get(list_rooms).post(create_room)) .route("/lan/join", post(join_room)) .route("/game", get(ws_handler)) .layer(CorsLayer::permissive()) .with_state(state); let listener = TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], HTTP_PORT))).await?; log::info!("LAN game server listening on 0.0.0.0:{HTTP_PORT}"); axum::serve(listener, app).await?; Ok(()) } async fn list_rooms(State(state): State) -> Json { let mut inner = state.inner.write().await; inner .discovered .retain(|_, room| room.seen_at.elapsed() < Duration::from_secs(4)); Json(RoomsResponse { rooms: inner .discovered .values() .map(|room| room.room.clone()) .collect(), }) } async fn create_room( State(state): State, Json(req): Json, ) -> Result, ApiError> { ensure_protocol(req.protocol_version)?; let room_id = Uuid::new_v4().to_string(); let player_id = Uuid::new_v4().to_string(); let ticket = Uuid::new_v4().to_string(); let pairing_code = format!("{:06}", rand::thread_rng().gen_range(0..1_000_000)); let mut players = HashMap::new(); players.insert( player_id.clone(), Player { id: player_id.clone(), name: sanitize_name(&req.player_name), role: PlayerRole::Host, ready: false, wants_restart: false, connected: false, tx: None, input: PlayerInput::default(), prev_pause: false, paddle_x: 0.0, score: 0, lives: 3, }, ); let mut tickets = HashMap::new(); tickets.insert(player_id.clone(), ticket.clone()); let room = Room { room_id: room_id.clone(), pairing_code: pairing_code.clone(), players, tickets, phase: GamePhase::Lobby, game: GameState::new(), }; state.inner.write().await.room = Some(room); Ok(Json(JoinRoomResponse { room_id, player_id, ticket, ws_url: format!("ws://{}:{HTTP_PORT}/game", state.lan_ip), pairing_code: Some(pairing_code), })) } async fn join_room( State(state): State, Json(req): Json, ) -> Result, ApiError> { ensure_protocol(req.protocol_version)?; if let Some(response) = try_join_local_room(&state, &req).await? { return Ok(Json(response)); } let remote = { let inner = state.inner.read().await; inner.discovered.get(&req.pairing_code).cloned() } .ok_or_else(|| ApiError::not_found("pairing code not found"))?; let client = reqwest::Client::builder() .timeout(Duration::from_secs(3)) .build() .map_err(|error| ApiError::upstream(error.to_string()))?; let response = client .post(format!("{}/lan/join", remote.http_url)) .json(&req) .send() .await .map_err(|error| ApiError::upstream(error.to_string()))?; if !response.status().is_success() { return Err(ApiError::upstream(format!( "remote join failed: {}", response.status() ))); } let body = response .json::() .await .map_err(|error| ApiError::upstream(error.to_string()))?; Ok(Json(body)) } async fn try_join_local_room( state: &AppState, req: &JoinRoomRequest, ) -> Result, ApiError> { let mut inner = state.inner.write().await; let Some(room) = inner.room.as_mut() else { return Ok(None); }; if room.pairing_code != req.pairing_code { return Ok(None); } if room.players.len() >= 2 { return Err(ApiError::bad_request("room is full")); } let player_id = Uuid::new_v4().to_string(); let ticket = Uuid::new_v4().to_string(); room.players.insert( player_id.clone(), Player { id: player_id.clone(), name: sanitize_name(&req.player_name), role: PlayerRole::Guest, ready: false, wants_restart: false, connected: false, tx: None, input: PlayerInput::default(), prev_pause: false, paddle_x: 0.0, score: 0, lives: 3, }, ); room.tickets.insert(player_id.clone(), ticket.clone()); Ok(Some(JoinRoomResponse { room_id: room.room_id.clone(), player_id, ticket, ws_url: format!("ws://{}:{HTTP_PORT}/game", state.lan_ip), pairing_code: Some(room.pairing_code.clone()), })) } async fn ws_handler(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { ws.on_upgrade(move |socket| handle_socket(socket, state)) } async fn handle_socket(mut socket: WebSocket, state: AppState) { let Some(Ok(Message::Text(text))) = socket.recv().await else { return; }; let Ok(ClientMessage::Hello { protocol_version, room_id, player_id, ticket, player_name, }) = serde_json::from_str::(&text) else { return; }; if protocol_version != PROTOCOL_VERSION { send_socket_error( &mut socket, "ProtocolMismatch", "protocol version mismatch", ) .await; return; } let (tx, mut rx) = mpsc::unbounded_channel::(); let role = { let mut inner = state.inner.write().await; let Some(room) = inner.room.as_mut() else { return; }; if room.room_id != room_id || room.tickets.get(&player_id) != Some(&ticket) { return; } let Some(player) = room.players.get_mut(&player_id) else { return; }; player.name = sanitize_name(&player_name); player.connected = true; player.tx = Some(tx.clone()); player.role.clone() }; let _ = tx.send(ServerMessage::Welcome { room_id: room_id.clone(), player_id: player_id.clone(), role, tick_rate: TICK_RATE as u32, seed: 1, }); broadcast_lobby(&state).await; let (mut sender, mut receiver) = socket.split(); tokio::spawn(async move { while let Some(message) = rx.recv().await { let Ok(text) = serde_json::to_string(&message) else { continue; }; if sender.send(Message::Text(text.into())).await.is_err() { break; } } }); while let Some(Ok(message)) = receiver.next().await { let Message::Text(text) = message else { continue; }; let Ok(message) = serde_json::from_str::(&text) else { continue; }; match message { ClientMessage::Ready { ready } => { set_player_ready(&state, &player_id, ready).await; broadcast_lobby(&state).await; } ClientMessage::Restart => { set_player_wants_restart(&state, &player_id).await; broadcast_lobby(&state).await; } ClientMessage::Input { seq, client_time, axis, launch, pause, } => { let _input_timing = (seq, client_time); set_player_input(&state, &player_id, axis, launch, pause).await; } ClientMessage::Ping { client_time } => { let _ = tx.send(ServerMessage::Pong { client_time, server_time: now_ms(), }); } ClientMessage::Hello { .. } => {} } } detach_player(&state, &player_id).await; broadcast_lobby(&state).await; } async fn send_socket_error(socket: &mut WebSocket, code: &str, message: &str) { let message = ServerMessage::Error { code: code.to_string(), message: message.to_string(), }; if let Ok(text) = serde_json::to_string(&message) { let _ = socket.send(Message::Text(text.into())).await; } } async fn set_player_ready(state: &AppState, player_id: &str, ready: bool) { let mut inner = state.inner.write().await; if let Some(room) = inner.room.as_mut() { if let Some(player) = room.players.get_mut(player_id) { player.ready = ready; } } } async fn set_player_wants_restart(state: &AppState, player_id: &str) { let mut inner = state.inner.write().await; if let Some(room) = inner.room.as_mut() { if let Some(player) = room.players.get_mut(player_id) { player.wants_restart = true; player.ready = false; } } } async fn set_player_input( state: &AppState, player_id: &str, axis: f32, launch: bool, pause: bool, ) { let mut inner = state.inner.write().await; if let Some(room) = inner.room.as_mut() { if let Some(player) = room.players.get_mut(player_id) { player.input.axis = axis.clamp(-1.0, 1.0); player.input.launch = launch; player.input.pause = pause; } } } async fn detach_player(state: &AppState, player_id: &str) { let mut inner = state.inner.write().await; if let Some(room) = inner.room.as_mut() { if let Some(player) = room.players.get_mut(player_id) { player.connected = false; player.ready = false; player.wants_restart = false; player.tx = None; } let remove_guest = room .players .get(player_id) .map(|player| player.role == PlayerRole::Guest) .unwrap_or(false); if remove_guest { room.players.remove(player_id); room.tickets.remove(player_id); } if room.players.values().filter(|player| player.connected).count() < 2 { room.phase = GamePhase::Lobby; } } } async fn broadcast_lobby(state: &AppState) { let message = { let inner = state.inner.read().await; let Some(room) = inner.room.as_ref() else { return; }; ServerMessage::Lobby { players: room .players .values() .map(|player| LobbyPlayer { id: player.id.clone(), name: player.name.clone(), ready: player.ready, role: player.role.clone(), }) .collect(), } }; broadcast(state, message).await; } async fn broadcast(state: &AppState, message: ServerMessage) { let senders = { let inner = state.inner.read().await; let Some(room) = inner.room.as_ref() else { return; }; room.players .values() .filter_map(|player| player.tx.clone()) .collect::>() }; for tx in senders { let _ = tx.send(message.clone()); } } async fn game_loop(state: AppState) { let mut ticker = time::interval(Duration::from_millis(1000 / TICK_RATE)); loop { ticker.tick().await; let should_broadcast_lobby = { let mut inner = state.inner.write().await; let Some(room) = inner.room.as_mut() else { continue; }; let snapshot = tick_room(room, 1.0 / TICK_RATE as f32); if let Some(snapshot) = snapshot { // Broadcast snapshot below (need to drop read lock first) // We'll collect the senders here let senders: Vec<_> = room .players .values() .filter_map(|player| player.tx.clone()) .collect(); let snapshot_msg = snapshot; drop(inner); for tx in senders { let _ = tx.send(snapshot_msg.clone()); } } false }; if should_broadcast_lobby { broadcast_lobby(&state).await; } } } fn tick_room(room: &mut Room, dt: f32) -> Option { // Handle Finished phase: check if both players want restart if room.phase == GamePhase::Finished { let connected_count = room .players .values() .filter(|player| player.connected) .count(); let all_want_restart = connected_count >= 2 && room .players .values() .filter(|player| player.connected) .all(|player| player.wants_restart); if all_want_restart { // Reset to lobby, everyone needs to re-ready room.phase = GamePhase::Lobby; room.game = GameState::new(); for player in room.players.values_mut() { player.ready = false; player.wants_restart = false; player.paddle_x = 0.0; player.score = 0; player.lives = 3; player.prev_pause = false; player.input = PlayerInput::default(); } return None; // Lobby snapshots are handled by broadcast_lobby } // Still finished - keep sending snapshots so frontend shows game over state return Some(build_snapshot(room)); } let connected_count = room .players .values() .filter(|player| player.connected) .count(); let all_ready = connected_count >= 2 && room .players .values() .filter(|player| player.connected) .all(|player| player.ready); if room.phase == GamePhase::Lobby && all_ready { start_match(room); } if room.phase == GamePhase::Lobby { return None; } let pause_pressed = room.players.values().any(|player| { player.connected && player.input.pause && !player.prev_pause }); for player in room.players.values_mut() { player.prev_pause = player.input.pause; } if pause_pressed && room.game.last_pause_toggle.elapsed() > Duration::from_millis(640) { room.phase = match room.phase { GamePhase::Running => GamePhase::Paused, GamePhase::Paused => GamePhase::Running, other => other, }; room.game.last_pause_toggle = Instant::now(); } if room.phase == GamePhase::Running { update_game(room, dt); } Some(build_snapshot(room)) } fn start_match(room: &mut Room) { room.phase = GamePhase::Running; room.game = GameState::new(); for player in room.players.values_mut() { player.paddle_x = 0.0; player.score = 0; player.lives = 3; player.ready = true; player.wants_restart = false; player.prev_pause = false; player.input = PlayerInput::default(); } } fn update_game(room: &mut Room, dt: f32) { for player in room.players.values_mut() { if !player.connected { continue; } player.paddle_x = (player.paddle_x + player.input.axis * PADDLE_SPEED * dt) .clamp( -FIELD_HALF_W + PADDLE_W * 0.5, FIELD_HALF_W - PADDLE_W * 0.5, ); } // Check if any connected player has sent a launch input if !room.game.ball_launched { let any_launch = room .players .values() .any(|player| player.connected && player.input.launch); if any_launch { room.game.ball_launched = true; let mut rng = rand::thread_rng(); let direction_x: f32 = rng.gen_range(-0.72..0.72); let len = (direction_x * direction_x + 1.0).sqrt(); room.game.ball_vx = direction_x / len * BASE_BALL_SPEED; room.game.ball_vy = 1.0 / len * BASE_BALL_SPEED; } else { // Ball stays at paddle position, still send snapshots let has_connected = room.players.values().any(|player| player.connected); if has_connected { room.game.tick += 1; } return; } } room.game.ball_x += room.game.ball_vx * dt; room.game.ball_y += room.game.ball_vy * dt; if room.game.ball_x - BALL_RADIUS <= -FIELD_HALF_W { room.game.ball_x = -FIELD_HALF_W + BALL_RADIUS; room.game.ball_vx = room.game.ball_vx.abs(); } else if room.game.ball_x + BALL_RADIUS >= FIELD_HALF_W { room.game.ball_x = FIELD_HALF_W - BALL_RADIUS; room.game.ball_vx = -room.game.ball_vx.abs(); } if room.game.ball_y + BALL_RADIUS >= FIELD_HALF_H { room.game.ball_y = FIELD_HALF_H - BALL_RADIUS; room.game.ball_vy = -room.game.ball_vy.abs(); } resolve_paddle_collision(room); resolve_brick_collision(room); if room.game.ball_y - BALL_RADIUS < -FIELD_HALF_H { for player in room .players .values_mut() .filter(|player| player.connected) { player.lives -= 1; } if room .players .values() .filter(|player| player.connected) .all(|player| player.lives <= 0) { room.phase = GamePhase::Finished; } else { reset_ball(&mut room.game); } } room.game.tick += 1; } fn resolve_paddle_collision(room: &mut Room) { if room.game.ball_vy >= 0.0 { return; } let paddle_top = PADDLE_Y + PADDLE_H * 0.5; let paddle_bottom = PADDLE_Y - PADDLE_H * 0.5; for player in room.players.values() { if !player.connected { continue; } let left = player.paddle_x - PADDLE_W * 0.5; let right = player.paddle_x + PADDLE_W * 0.5; let hit = room.game.ball_y - BALL_RADIUS <= paddle_top && room.game.ball_y + BALL_RADIUS >= paddle_bottom && room.game.ball_x >= left - BALL_RADIUS && room.game.ball_x <= right + BALL_RADIUS; if !hit { continue; } room.game.ball_y = paddle_top + BALL_RADIUS + 0.04; let offset = (room.game.ball_x - player.paddle_x) / (PADDLE_W * 0.5); room.game.ball_vx = offset * BASE_BALL_SPEED * 0.9; room.game.ball_vy = BASE_BALL_SPEED.abs(); normalize_ball_speed(&mut room.game); break; } } fn resolve_brick_collision(room: &mut Room) { for index in 0..room.game.bricks.len() { if !room.game.bricks[index] { continue; } let (left, right, top, bottom) = brick_bounds(index); let closest_x = room.game.ball_x.clamp(left, right); let closest_y = room.game.ball_y.clamp(bottom, top); let dx = room.game.ball_x - closest_x; let dy = room.game.ball_y - closest_y; if dx * dx + dy * dy > BALL_RADIUS * BALL_RADIUS { continue; } let overlap_x = (room.game.ball_x + BALL_RADIUS - left) .min(right - (room.game.ball_x - BALL_RADIUS)); let overlap_y = (room.game.ball_y + BALL_RADIUS - bottom) .min(top - (room.game.ball_y - BALL_RADIUS)); if overlap_x < overlap_y { room.game.ball_vx *= -1.0; } else { room.game.ball_vy *= -1.0; } room.game.bricks[index] = false; for player in room .players .values_mut() .filter(|player| player.connected) { player.score += 40; } if room.game.bricks.iter().all(|alive| !alive) { room.game.bricks = vec![true; BRICK_COLS * BRICK_ROWS]; reset_ball(&mut room.game); } break; } } fn brick_bounds(index: usize) -> (f32, f32, f32, f32) { let row = index / BRICK_COLS; let col = index % BRICK_COLS; let total_width = BRICK_COLS as f32 * BRICK_W + (BRICK_COLS as f32 - 1.0) * BRICK_GAP_X; let start_x = -total_width / 2.0 + BRICK_W / 2.0; let x = start_x + col as f32 * (BRICK_W + BRICK_GAP_X); let y = BRICK_TOP - row as f32 * (BRICK_H + BRICK_GAP_Y); ( x - BRICK_W / 2.0, x + BRICK_W / 2.0, y + BRICK_H / 2.0, y - BRICK_H / 2.0, ) } fn reset_ball(game: &mut GameState) { game.ball_x = 0.0; game.ball_y = PADDLE_Y + PADDLE_H * 0.5 + BALL_RADIUS + 0.4; game.ball_vx = 0.0; game.ball_vy = 0.0; game.ball_launched = false; } fn normalize_ball_speed(game: &mut GameState) { let len = (game.ball_vx * game.ball_vx + game.ball_vy * game.ball_vy).sqrt(); if len <= 0.01 { game.ball_vx = 18.0; game.ball_vy = BASE_BALL_SPEED; return; } game.ball_vx = game.ball_vx / len * BASE_BALL_SPEED; game.ball_vy = game.ball_vy / len * BASE_BALL_SPEED; } fn build_snapshot(room: &Room) -> ServerMessage { ServerMessage::Snapshot { tick: room.game.tick, server_time: now_ms(), phase: match room.phase { GamePhase::Lobby => "lobby", GamePhase::Running => "running", GamePhase::Paused => "paused", GamePhase::Finished => "finished", } .to_string(), players: room .players .values() .map(|player| SnapshotPlayer { id: player.id.clone(), name: player.name.clone(), role: player.role.clone(), ready: player.ready, paddle_x: player.paddle_x, score: player.score, lives: player.lives, }) .collect(), ball: BallState { x: room.game.ball_x, y: room.game.ball_y, vx: room.game.ball_vx, vy: room.game.ball_vy, }, bricks: room .game .bricks .iter() .map(|alive| if *alive { '1' } else { '0' }) .collect(), } } async fn discovery_broadcast(state: AppState) { let socket = UdpSocket::bind("0.0.0.0:0") .await .expect("bind UDP sender"); socket.set_broadcast(true).expect("enable UDP broadcast"); loop { let beacon = { let inner = state.inner.read().await; inner.room.as_ref().map(|room| Beacon { magic: MAGIC.to_string(), protocol_version: PROTOCOL_VERSION, room_id: room.room_id.clone(), pairing_code: room.pairing_code.clone(), host_name: host_name(), http_url: format!("http://{}:{HTTP_PORT}", state.lan_ip), players: room.players.len(), max_players: 2, app_version: env!("CARGO_PKG_VERSION").to_string(), }) }; if let Some(beacon) = beacon { if let Ok(bytes) = serde_json::to_vec(&beacon) { let _ = socket .send_to( &bytes, SocketAddr::from(([255, 255, 255, 255], DISCOVERY_PORT)), ) .await; } } time::sleep(Duration::from_millis(800)).await; } } async fn discovery_recv(state: AppState) { let socket = UdpSocket::bind(("0.0.0.0", DISCOVERY_PORT)) .await .expect("bind UDP receiver"); let mut buf = [0u8; 2048]; loop { let Ok((len, addr)) = socket.recv_from(&mut buf).await else { continue; }; let Ok(beacon) = serde_json::from_slice::(&buf[..len]) else { continue; }; if beacon.magic != MAGIC || beacon.protocol_version != PROTOCOL_VERSION { continue; } let own_room_id = { let inner = state.inner.read().await; inner.room.as_ref().map(|room| room.room_id.clone()) }; if own_room_id.as_deref() == Some(&beacon.room_id) { continue; } let room = LanRoom { room_id: beacon.room_id, pairing_code: beacon.pairing_code.clone(), host_name: beacon.host_name, address: addr.ip().to_string(), port: HTTP_PORT, players: beacon.players, max_players: beacon.max_players, protocol_version: beacon.protocol_version, app_version: beacon.app_version, }; state.inner.write().await.discovered.insert( beacon.pairing_code, DiscoveredRoom { room, http_url: beacon.http_url, seen_at: Instant::now(), }, ); } } impl GameState { fn new() -> Self { let mut game = Self { tick: 0, ball_x: 0.0, ball_y: 0.0, ball_vx: 0.0, ball_vy: 0.0, ball_launched: false, bricks: vec![true; BRICK_COLS * BRICK_ROWS], last_pause_toggle: Instant::now() - Duration::from_secs(1), }; reset_ball(&mut game); game } } fn ensure_protocol(version: u8) -> Result<(), ApiError> { if version == PROTOCOL_VERSION { Ok(()) } else { Err(ApiError::bad_request("protocol version mismatch")) } } fn sanitize_name(name: &str) -> String { let name = name.trim(); if name.is_empty() { "Player".to_string() } else { name.chars().take(18).collect() } } fn now_ms() -> f64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as f64 } fn guess_lan_ip() -> IpAddr { std::net::UdpSocket::bind("0.0.0.0:0") .and_then(|socket| { socket.connect("8.8.8.8:80")?; socket.local_addr() }) .map(|addr| addr.ip()) .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)) } fn host_name() -> String { std::env::var("COMPUTERNAME") .or_else(|_| std::env::var("HOSTNAME")) .unwrap_or_else(|_| "JE-Skin Host".to_string()) }