feat: add Rust Axum backend with WebSocket game server

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
eros 2026-06-24 08:18:07 -07:00
parent 0df556ff46
commit 5e0c188d9b
7 changed files with 2550 additions and 0 deletions

1
backend/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1452
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

20
backend/Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
[package]
name = "curltastic-backend"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rapier2d = "0.33"
uuid = { version = "1", features = ["v4"] }
futures-util = "0.3"
tokio-util = "0.7"
tracing = "0.1"
tracing-subscriber = "0.3"
rand = "0.8"
[dev-dependencies]
tokio-test = "0.4"

354
backend/src/game.rs Normal file
View File

@ -0,0 +1,354 @@
use crate::protocol::*;
use crate::physics::PhysicsWorld;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GamePhase {
Waiting,
Playing,
Simulating,
Scoring,
EndComplete,
GameComplete,
}
pub struct Game {
red: Option<Player>,
yellow: Option<Player>,
phase: GamePhase,
end: u8,
scores: [i32; 2],
hammer: Team,
turn_team: Team,
physics: PhysicsWorld,
active_stones: Vec<StoneState>,
stones_red: u8,
stones_yellow: u8,
last_end_scored: Option<(u8, i32, Option<Team>)>,
pub room_tx: Option<tokio::sync::broadcast::Sender<ServerMessage>>,
}
impl Game {
pub fn new() -> Self {
Self {
red: None,
yellow: None,
phase: GamePhase::Waiting,
end: 1,
scores: [0, 0],
hammer: Team::Red,
turn_team: Team::Red,
physics: PhysicsWorld::new(),
active_stones: Vec::new(),
stones_red: STONES_PER_TEAM,
stones_yellow: STONES_PER_TEAM,
last_end_scored: None,
room_tx: None,
}
}
pub fn add_player(&mut self, id: String, preferred: Option<Team>) -> Option<Team> {
let team = match preferred {
Some(Team::Red) if self.red.is_none() => Some(Team::Red),
Some(Team::Yellow) if self.yellow.is_none() => Some(Team::Yellow),
Some(_) => {
// Preferred is taken; fall back to the other free team.
if self.red.is_none() {
Some(Team::Red)
} else if self.yellow.is_none() {
Some(Team::Yellow)
} else {
None
}
}
None => {
// Legacy first-free logic.
if self.red.is_none() {
Some(Team::Red)
} else if self.yellow.is_none() {
Some(Team::Yellow)
} else {
None
}
}
};
if let Some(t) = team {
let player = Player { id, team: t, connected: true };
match t {
Team::Red => self.red = Some(player),
Team::Yellow => self.yellow = Some(player),
}
Some(t)
} else {
None
}
}
pub fn remove_player(&mut self, id: &str) -> Option<Team> {
if let Some(ref p) = self.red {
if p.id == id {
self.red = None;
return Some(Team::Red);
}
}
if let Some(ref p) = self.yellow {
if p.id == id {
self.yellow = None;
return Some(Team::Yellow);
}
}
None
}
pub fn set_player_connected(&mut self, id: &str, connected: bool) {
if let Some(p) = self.red.as_mut().filter(|p| p.id == id) {
p.connected = connected;
}
if let Some(p) = self.yellow.as_mut().filter(|p| p.id == id) {
p.connected = connected;
}
}
pub fn can_start(&self) -> bool {
self.red.is_some() && self.yellow.is_some()
}
pub fn start(&mut self) {
if !self.can_start() {
return;
}
self.hammer = if rand::random() { Team::Red } else { Team::Yellow };
self.turn_team = self.hammer.other();
self.phase = GamePhase::Playing;
self.end = 1;
self.stones_red = STONES_PER_TEAM;
self.stones_yellow = STONES_PER_TEAM;
self.scores = [0, 0];
self.physics.reset();
self.physics.reset_stone_ids();
self.active_stones.clear();
}
pub fn current_player_id(&self) -> Option<&str> {
let p = match self.turn_team {
Team::Red => self.red.as_ref()?,
Team::Yellow => self.yellow.as_ref()?,
};
Some(&p.id)
}
pub fn current_team_for_player(&self, player_id: &str) -> Option<Team> {
if let Some(p) = self.red.as_ref() { if p.id == player_id { return Some(Team::Red); } }
if let Some(p) = self.yellow.as_ref() { if p.id == player_id { return Some(Team::Yellow); } }
None
}
pub fn handle_throw(&mut self, player_id: &str, broom_x: f32, broom_y: f32, weight: u8, curl: i8, friction: f32) -> Result<Vec<(f32, f32, f32)>, String> {
let current_id = self.current_player_id().ok_or("No current player")?;
if current_id != player_id {
return Err("Not your turn".to_string());
}
if self.phase != GamePhase::Playing {
return Err("Cannot throw now".to_string());
}
let dx = broom_x - HOUSE_CENTER.0;
let dy = broom_y - HOUSE_CENTER.1;
let dist = (dx * dx + dy * dy).sqrt();
if dist > HOUSE_RADIUS {
return Err("Broom must be in the house".to_string());
}
self.active_stones.clear();
let path = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?;
self.active_stones = self.physics.current_stones();
self.phase = GamePhase::Simulating;
match self.turn_team {
Team::Red => self.stones_red = self.stones_red.saturating_sub(1),
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1),
}
Ok(path)
}
pub fn finish_simulation(&mut self) {
if self.phase != GamePhase::Simulating {
return;
}
self.active_stones = self.physics.current_stones();
self.score_end_internal(false);
}
pub fn score_end(&mut self) {
self.score_end_internal(true);
}
fn score_end_internal(&mut self, force: bool) {
let end_done = force || (self.stones_red == 0 && self.stones_yellow == 0);
if !end_done {
self.phase = GamePhase::Playing;
self.turn_team = self.turn_team.other();
return;
}
self.phase = GamePhase::Scoring;
let states = self.physics.stone_states_for_scoring();
let mut by_distance: Vec<_> = states.iter()
.map(|(id, team, x, y)| {
let dx = x - HOUSE_CENTER.0;
let dy = y - HOUSE_CENTER.1;
let dist = (dx * dx + dy * dy).sqrt();
(dist, *id, *team, *x, *y)
})
.filter(|(dist, _, _, _, _)| *dist <= HOUSE_RADIUS)
.collect();
by_distance.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let scoring_team: Option<Team> = by_distance.first().map(|(_, _, team, _, _)| *team);
let mut points = 0;
if let Some(team) = scoring_team {
for (_, _, t, _, _) in &by_distance {
if *t == team {
points += 1;
} else {
break;
}
}
if points > 0 {
let team_idx = match team {
Team::Red => 0,
Team::Yellow => 1,
};
self.scores[team_idx] += points as i32;
self.hammer = team.other();
} else {
points = 0;
}
}
self.last_end_scored = Some((self.end, points, scoring_team));
self.phase = GamePhase::EndComplete;
self.end_ends_or_continue(points, scoring_team);
}
fn end_ends_or_continue(&mut self, _points: i32, _scoring_team: Option<Team>) {
let tied = self.scores[0] == self.scores[1];
let after_regulation = self.end >= ENDS;
if after_regulation && !tied {
self.phase = GamePhase::GameComplete;
return;
}
if after_regulation && tied {
// Extra end
}
self.end += 1;
self.stones_red = STONES_PER_TEAM;
self.stones_yellow = STONES_PER_TEAM;
self.active_stones.clear();
self.physics.reset();
self.physics.reset_stone_ids();
self.turn_team = self.hammer.other();
self.phase = GamePhase::Playing;
}
pub fn phase(&self) -> GamePhase {
self.phase
}
pub fn take_last_end_scored(&mut self) -> Option<ServerMessage> {
let msg = self.last_end_scored.map(|(end, points, scoring_team)| {
ServerMessage::EndScored { end, points, scoring_team }
});
self.last_end_scored = None;
msg
}
pub fn game_state_message(&self) -> ServerMessage {
ServerMessage::GameState {
end: self.end,
scores: self.scores,
hammer: self.hammer,
turn_team: self.turn_team,
stones: self.active_stones.clone(),
phase: match self.phase {
GamePhase::Waiting => Phase::Waiting,
GamePhase::Playing => Phase::Playing,
GamePhase::Simulating => Phase::Simulating,
GamePhase::Scoring => Phase::Scoring,
GamePhase::EndComplete => Phase::EndComplete,
GamePhase::GameComplete => Phase::GameComplete,
},
}
}
pub fn game_over_message(&self) -> ServerMessage {
let winner = if self.scores[0] > self.scores[1] {
Some(Team::Red)
} else if self.scores[1] > self.scores[0] {
Some(Team::Yellow)
} else {
None
};
ServerMessage::GameOver {
scores: self.scores,
winner,
}
}
}
pub type RoomId = String;
pub struct Room {
pub id: RoomId,
pub game: Game,
pub tx: tokio::sync::broadcast::Sender<ServerMessage>,
}
impl Room {
pub fn new(id: &str) -> Self {
let (tx, _) = tokio::sync::broadcast::channel(256);
let mut room = Self {
id: id.to_string(),
game: Game::new(),
tx,
};
room.game.room_tx = Some(room.tx.clone());
room
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefers_red_when_requested_and_free() {
let mut game = Game::new();
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red));
}
#[test]
fn prefers_yellow_when_requested_and_free() {
let mut game = Game::new();
assert_eq!(game.add_player("p1".into(), Some(Team::Yellow)), Some(Team::Yellow));
}
#[test]
fn falls_back_when_preferred_taken() {
let mut game = Game::new();
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red));
assert_eq!(game.add_player("p2".into(), Some(Team::Red)), Some(Team::Yellow));
}
#[test]
fn legacy_order_without_preference() {
let mut game = Game::new();
assert_eq!(game.add_player("p1".into(), None), Some(Team::Red));
assert_eq!(game.add_player("p2".into(), None), Some(Team::Yellow));
assert_eq!(game.add_player("p3".into(), None), None);
}
}

217
backend/src/main.rs Normal file
View File

@ -0,0 +1,217 @@
mod protocol;
mod physics;
mod game;
use axum::{
extract::{Query, State, WebSocketUpgrade},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Router,
};
use axum::extract::ws::{Message, Utf8Bytes, WebSocket};
use futures_util::{sink::SinkExt, stream::StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex;
use tracing::{info, warn};
use uuid::Uuid;
use crate::game::{GamePhase, Room};
use crate::protocol::*;
#[derive(Clone)]
struct AppState {
rooms: Arc<Mutex<HashMap<String, Arc<TokioMutex<Room>>>>>,
}
impl AppState {
fn new() -> Self {
Self { rooms: Arc::new(Mutex::new(HashMap::new())) }
}
fn get_or_create_room(&self, room_id: &str) -> Arc<TokioMutex<Room>> {
let mut rooms = self.rooms.lock().unwrap();
if let Some(room) = rooms.get(room_id).cloned() {
return room;
}
let room = Arc::new(TokioMutex::new(Room::new(room_id)));
rooms.insert(room_id.to_string(), room.clone());
room
}
}
#[derive(Deserialize)]
struct RoomQuery {
room: String,
team: Option<Team>,
}
#[derive(Serialize)]
struct NewRoomResponse {
room: String,
}
fn generate_room_code() -> String {
let s = Uuid::new_v4().to_string().replace("-", "");
s.chars().take(6).collect::<String>().to_uppercase()
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let state = Arc::new(AppState::new());
let app = Router::new()
.route("/", get(health))
.route("/room", post(new_room))
.route("/ws", get(ws_handler))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
info!("Server listening on 0.0.0.0:3000");
axum::serve(listener, app).await.unwrap();
}
async fn health() -> impl IntoResponse {
"curltastic ok"
}
async fn new_room(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let room_id = generate_room_code();
state.get_or_create_room(&room_id);
(StatusCode::OK, axum::Json(NewRoomResponse { room: room_id }))
}
async fn ws_handler(
ws: WebSocketUpgrade,
Query(query): Query<RoomQuery>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state, query.room, query.team))
}
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String, preferred_team: Option<Team>) {
let room_arc = state.get_or_create_room(&room_id);
let player_id = Uuid::new_v4().to_string();
let player_id_for_recv = player_id.clone();
let (mut sender, mut receiver) = socket.split();
// Reject room-full before subscribing so the error goes only to the joining socket.
{
let room = room_arc.lock().await;
if room.game.can_start() {
let err = serde_json::to_string(&ServerMessage::Error {
message: "Room is full".to_string(),
}).unwrap();
let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await;
return;
}
}
// Add the player to the room and tell only this socket its assigned team.
let team = {
let mut room = room_arc.lock().await;
let team = room.game.add_player(player_id.clone(), preferred_team)
.unwrap_or(Team::Red);
if room.game.can_start() {
room.game.start();
}
team
};
let joined_msg = serde_json::to_string(&ServerMessage::Joined {
room: room_id.clone(),
team,
}).unwrap();
let _ = sender.send(Message::Text(Utf8Bytes::from(joined_msg))).await;
// Subscribe to broadcast and spawn the forwarding task.
let tx = {
let room = room_arc.lock().await;
room.tx.clone()
};
let mut rx = tx.subscribe();
let send_task = tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(msg) => {
let text = match serde_json::to_string(&msg) {
Ok(t) => t,
Err(_) => continue,
};
if sender.send(Message::Text(Utf8Bytes::from(text))).await.is_err() {
break;
}
}
Err(_) => break,
}
}
});
// Broadcast waiting/game state to everyone in the room.
{
let room = room_arc.lock().await;
if room.game.can_start() {
let state_msg = room.game.game_state_message();
let _ = room.tx.send(state_msg);
} else {
let _ = room.tx.send(ServerMessage::Waiting { message: "Waiting for other player".to_string() });
}
}
let recv_room = room_arc.clone();
let recv_id = player_id_for_recv;
let recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.next().await {
let Message::Text(text) = msg else { continue; };
let text_ref = text.as_str();
let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref);
match parsed {
Ok(ClientMessage::Throw { broom_x, broom_y, weight, curl, friction }) => {
let room = recv_room.clone();
let mut room = room.lock().await;
if room.game.current_team_for_player(&recv_id) != Some(team) {
let _ = room.tx.send(ServerMessage::Error { message: "Not your turn".to_string() });
continue;
}
match room.game.handle_throw(&recv_id, broom_x, broom_y, weight, curl, friction) {
Ok(path) => {
room.tx.send(ServerMessage::Trajectory { path }).ok();
room.game.finish_simulation();
if let Some(scored) = room.game.take_last_end_scored() {
room.tx.send(scored).ok();
}
let after = room.game.game_state_message();
room.tx.send(after).ok();
if room.game.phase() == GamePhase::GameComplete {
room.tx.send(room.game.game_over_message()).ok();
}
}
Err(e) => {
room.tx.send(ServerMessage::Error { message: e }).ok();
}
}
}
Err(e) => {
warn!("Invalid message: {}", e);
}
}
}
});
tokio::select! {
_ = send_task => {}
_ = recv_task => {}
}
// On disconnect, free the team slot so refreshes and new tabs can rejoin.
{
let mut room = room_arc.lock().await;
let _ = room.game.remove_player(&player_id);
}
}

380
backend/src/physics.rs Normal file
View File

@ -0,0 +1,380 @@
use rapier2d::prelude::*;
use crate::protocol::*;
const LINEAR_DAMPING: f32 = 0.142;
const ANGULAR_DAMPING: f32 = 0.18;
const MAX_SIM_TIME: f32 = 30.0;
const REST_SPEED: f32 = 0.04;
const REST_ANGULAR_SPEED: f32 = 0.05;
// Rotation rate of the velocity vector, in rad/s.
// Positive curl_sign = right curl -> curves toward +x when moving up-sheet.
const CURL_RATE: f32 = 0.010;
pub struct PhysicsWorld {
gravity: Vector,
integration_parameters: IntegrationParameters,
pipeline: PhysicsPipeline,
islands: IslandManager,
broad_phase: DefaultBroadPhase,
narrow_phase: NarrowPhase,
bodies: RigidBodySet,
colliders: ColliderSet,
impulse_joints: ImpulseJointSet,
multibody_joints: MultibodyJointSet,
ccd_solver: CCDSolver,
next_stone_id: u32,
stone_handles: Vec<(u32, RigidBodyHandle, Team, i8)>,
}
impl Default for PhysicsWorld {
fn default() -> Self {
Self::new()
}
}
impl PhysicsWorld {
pub fn new() -> Self {
let mut integration_parameters = IntegrationParameters::default();
integration_parameters.dt = PHYSICS_DT;
integration_parameters.num_solver_iterations = 8;
let mut world = Self {
gravity: Vector::new(0.0, 0.0),
integration_parameters,
pipeline: PhysicsPipeline::new(),
islands: IslandManager::new(),
broad_phase: DefaultBroadPhase::new(),
narrow_phase: NarrowPhase::new(),
bodies: RigidBodySet::new(),
colliders: ColliderSet::new(),
impulse_joints: ImpulseJointSet::new(),
multibody_joints: MultibodyJointSet::new(),
ccd_solver: CCDSolver::new(),
next_stone_id: 1,
stone_handles: Vec::new(),
};
world.build_sheet();
world
}
pub fn reset(&mut self) {
self.bodies = RigidBodySet::new();
self.colliders = ColliderSet::new();
self.islands = IslandManager::new();
self.broad_phase = DefaultBroadPhase::new();
self.narrow_phase = NarrowPhase::new();
self.impulse_joints = ImpulseJointSet::new();
self.multibody_joints = MultibodyJointSet::new();
self.ccd_solver = CCDSolver::new();
self.stone_handles.clear();
self.build_sheet();
}
pub fn reset_stone_ids(&mut self) {
self.next_stone_id = 1;
}
fn build_sheet(&mut self) {
let half = SHEET_WIDTH / 2.0 + 0.1;
let left = ColliderBuilder::cuboid(0.1, SHEET_LENGTH / 2.0 + 1.0)
.translation(Vector::new(-half, SHEET_LENGTH / 2.0))
.friction(0.0)
.restitution(0.0)
.build();
self.colliders.insert(left);
let right = ColliderBuilder::cuboid(0.1, SHEET_LENGTH / 2.0 + 1.0)
.translation(Vector::new(half, SHEET_LENGTH / 2.0))
.friction(0.0)
.restitution(0.0)
.build();
self.colliders.insert(right);
let back = ColliderBuilder::cuboid(SHEET_WIDTH / 2.0 + 1.0, 0.1)
.translation(Vector::new(0.0, SHEET_LENGTH + 0.1))
.friction(0.0)
.restitution(0.1)
.build();
self.colliders.insert(back);
}
pub fn throw(
&mut self,
team: Team,
broom_x: f32,
broom_y: f32,
weight: u8,
curl: i8,
friction: f32,
) -> Result<Vec<(f32, f32, f32)>, String> {
let weight = weight.clamp(1, 10) as f32;
let t = (weight - 1.0) / 9.0;
let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
let dx = broom_x;
let dy = broom_y - HACK_Y;
let len = (dx * dx + dy * dy).sqrt().max(0.01);
let vx = dx / len * speed;
let vy = dy / len * speed;
let curl_sign = if curl < 0 { -1 } else { 1 };
let damping_mult = friction.clamp(0.5, 2.0);
self.spawn_stone(team, 0.0, HACK_Y, vx, vy, curl_sign, damping_mult)
}
fn spawn_stone(
&mut self,
team: Team,
x: f32,
y: f32,
vx: f32,
vy: f32,
curl_sign: i8,
damping_mult: f32,
) -> Result<Vec<(f32, f32, f32)>, String> {
let id = self.next_stone_id;
self.next_stone_id += 1;
let body = RigidBodyBuilder::dynamic()
.translation(Vector::new(x, y))
.linvel(Vector::new(vx, vy))
.angvel(0.0)
.linear_damping(LINEAR_DAMPING * damping_mult)
.angular_damping(ANGULAR_DAMPING)
.can_sleep(false)
.build();
let handle = self.bodies.insert(body);
let collider = ColliderBuilder::ball(STONE_RADIUS)
.friction(STONE_FRICTION)
.friction_combine_rule(CoefficientCombineRule::Average)
.restitution(STONE_RESTITUTION)
.restitution_combine_rule(CoefficientCombineRule::Average)
.density(STONE_MASS / (std::f32::consts::PI * STONE_RADIUS * STONE_RADIUS))
.build();
self.colliders.insert_with_parent(collider, handle, &mut self.bodies);
self.stone_handles.push((id, handle, team, curl_sign));
self.simulate_until_rest(id)
}
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<(f32, f32, f32)>, String> {
let mut path: Vec<(f32, f32, f32)> = Vec::new();
let sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
let mut sample_accum: f32 = 0.0;
let mut time: f32 = 0.0;
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) {
let body = &self.bodies[*h];
path.push((body.translation().x, body.translation().y, time));
}
loop {
self.step();
self.apply_curl();
time += PHYSICS_DT;
sample_accum += PHYSICS_DT;
if sample_accum >= sample_step {
sample_accum -= sample_step;
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) {
let body = &self.bodies[*h];
path.push((body.translation().x, body.translation().y, time));
}
}
if self.all_stones_at_rest() || time > MAX_SIM_TIME {
break;
}
}
self.prune_out_of_play();
Ok(path)
}
// Rotate each stone's velocity slightly based on its selected curl direction.
// Right curl (curl_sign = +1) curves toward +x when moving up-sheet (positive y).
fn apply_curl(&mut self) {
for (_, handle, _, curl_sign) in &self.stone_handles {
let body = match self.bodies.get_mut(*handle) {
Some(b) => b,
None => continue,
};
let v = body.linvel();
let speed_sq = v.x * v.x + v.y * v.y;
let speed = speed_sq.sqrt();
if speed < 1e-4 {
continue;
}
let angle = -(*curl_sign as f32) * CURL_RATE * PHYSICS_DT;
let cos = angle.cos();
let sin = angle.sin();
let new_v = Vector::new(v.x * cos - v.y * sin, v.x * sin + v.y * cos);
body.set_linvel(new_v, true);
}
}
fn prune_out_of_play(&mut self) {
let mut keep = Vec::new();
for (id, handle, team, curl) in self.stone_handles.drain(..) {
if let Some(body) = self.bodies.get(handle) {
let pos = body.translation();
let beyond_back = pos.y > BACK_LINE_Y;
let short_of_hog = pos.y < HOG_LINE_Y;
let outside = pos.x.abs() > SHEET_WIDTH / 2.0;
if beyond_back || short_of_hog || outside {
self.bodies.remove(handle, &mut self.islands, &mut self.colliders, &mut self.impulse_joints, &mut self.multibody_joints, true);
} else {
keep.push((id, handle, team, curl));
}
}
}
self.stone_handles = keep;
}
fn step(&mut self) {
self.pipeline.step(
self.gravity,
&self.integration_parameters,
&mut self.islands,
&mut self.broad_phase,
&mut self.narrow_phase,
&mut self.bodies,
&mut self.colliders,
&mut self.impulse_joints,
&mut self.multibody_joints,
&mut self.ccd_solver,
&(),
&(),
);
}
fn all_stones_at_rest(&self) -> bool {
for (_, handle, _, _) in &self.stone_handles {
if let Some(body) = self.bodies.get(*handle) {
let v = body.linvel();
let speed = (v.x * v.x + v.y * v.y).sqrt();
if speed > REST_SPEED || body.angvel().abs() > REST_ANGULAR_SPEED {
return false;
}
}
}
true
}
pub fn current_stones(&self) -> Vec<StoneState> {
let mut states = Vec::new();
for (id, handle, team, _) in &self.stone_handles {
if let Some(body) = self.bodies.get(*handle) {
let pos = body.translation();
states.push(StoneState {
id: *id,
team: *team,
x: pos.x,
y: pos.y,
rotation: body.rotation().angle(),
active: false,
});
}
}
states
}
pub fn stone_states_for_scoring(&self) -> Vec<(u32, Team, f32, f32)> {
let mut out = Vec::new();
for (id, handle, team, _) in &self.stone_handles {
if let Some(body) = self.bodies.get(*handle) {
let pos = body.translation();
out.push((*id, *team, pos.x, pos.y));
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn final_y(world: &PhysicsWorld, id: u32) -> f32 {
world.stone_handles.iter()
.find(|(sid, _, _, _)| *sid == id)
.map(|(_, h, _, _)| {
let b = &world.bodies[*h];
b.translation().y
})
.unwrap_or(f32::NAN)
}
fn final_x(world: &PhysicsWorld, id: u32) -> f32 {
world.stone_handles.iter()
.find(|(sid, _, _, _)| *sid == id)
.map(|(_, h, _, _)| {
let b = &world.bodies[*h];
b.translation().x
})
.unwrap_or(f32::NAN)
}
#[test]
fn weight_7_lands_on_tee_line() {
let mut world = PhysicsWorld::new();
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap();
let id = world.next_stone_id - 1;
let y = final_y(&world, id);
println!("weight 7 final y={}", y);
assert!(
(y - HOUSE_CENTER.1).abs() <= 0.5,
"weight-7 draw shot should finish on the tee line, got y={}",
y
);
}
#[test]
fn curl_direction_mirrors_x_offset() {
let mut right = PhysicsWorld::new();
right.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap();
let right_id = right.next_stone_id - 1;
let right_x = final_x(&right, right_id);
let mut left = PhysicsWorld::new();
left.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, -1, 1.0).unwrap();
let left_id = left.next_stone_id - 1;
let left_x = final_x(&left, left_id);
println!("right curl final x={} left curl final x={}", right_x, left_x);
assert!(
right_x > left_x + 0.05,
"right curl should finish to the right of left curl: right={} left={}",
right_x,
left_x
);
}
#[test]
fn stones_persist_after_multiple_throws() {
let mut world = PhysicsWorld::new();
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap();
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, -1, 1.0).unwrap();
let stones = world.current_stones();
assert_eq!(stones.len(), 2, "both stones should remain in the physics world");
assert_eq!(stones[0].id, 1);
assert_eq!(stones[1].id, 2);
}
#[test]
fn out_of_play_stone_is_pruned() {
// A very light, high-friction throw should stop short of the hog line and be removed.
let mut world = PhysicsWorld::new();
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 1, 0, 2.0).unwrap();
let stones = world.current_stones();
assert!(stones.is_empty(), "stones short of the hog line should be pruned");
}
}

126
backend/src/protocol.rs Normal file
View File

@ -0,0 +1,126 @@
use serde::{Deserialize, Serialize};
use std::fmt;
pub const TICK_RATE_HZ: u16 = 120;
pub const SAMPLE_RATE_HZ: u16 = 40;
pub const PHYSICS_DT: f32 = 1.0 / TICK_RATE_HZ as f32;
pub const STONES_PER_TEAM: u8 = 8;
pub const ENDS: u8 = 10;
// World coordinates in meters, y along sheet toward house.
pub const SHEET_WIDTH: f32 = 5.0;
pub const SHEET_LENGTH: f32 = 45.0;
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
pub const HOUSE_RADIUS: f32 = 1.83; // 12 ft
pub const BUTTON_RADIUS: f32 = 0.1524; // 0.5 ft
pub const FOUR_FT_RADIUS: f32 = 0.6096;
pub const EIGHT_FT_RADIUS: f32 = 1.2192;
pub const TWELVE_FT_RADIUS: f32 = 1.8288;
pub const HOG_LINE_Y: f32 = 21.0;
pub const BACK_LINE_Y: f32 = 42.0;
pub const HACK_Y: f32 = 2.0;
// Stone physical properties
pub const STONE_RADIUS: f32 = 0.15;
pub const STONE_MASS: f32 = 20.0;
pub const STONE_FRICTION: f32 = 0.015;
pub const STONE_RESTITUTION: f32 = 0.05;
pub const MIN_SPEED: f32 = 3.0;
pub const MAX_SPEED: f32 = 6.45;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum Team {
#[default]
Red,
Yellow,
}
impl Team {
pub fn other(self) -> Self {
match self {
Team::Red => Team::Yellow,
Team::Yellow => Team::Red,
}
}
}
impl fmt::Display for Team {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Team::Red => write!(f, "red"),
Team::Yellow => write!(f, "yellow"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
Throw {
broom_x: f32,
broom_y: f32,
weight: u8,
#[serde(default = "default_curl")]
curl: i8,
#[serde(default = "default_friction")]
friction: f32,
},
}
fn default_curl() -> i8 { 1 }
fn default_friction() -> f32 { 1.0 }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
Joined { room: String, team: Team },
Waiting { message: String },
GameState {
end: u8,
scores: [i32; 2], // red, yellow
hammer: Team,
turn_team: Team,
stones: Vec<StoneState>,
phase: Phase,
},
Trajectory {
path: Vec<(f32, f32, f32)>,
},
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
GameOver {
scores: [i32; 2],
winner: Option<Team>,
},
Error { message: String },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
#[default]
Waiting,
Playing,
Simulating,
Scoring,
EndComplete,
GameComplete,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoneState {
pub id: u32,
pub team: Team,
pub x: f32,
pub y: f32,
pub rotation: f32,
pub active: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Player {
pub id: String,
pub team: Team,
pub connected: bool,
}