From 1f673b4ebab54e887319c2087568f1932e41c04b Mon Sep 17 00:00:00 2001 From: eros Date: Wed, 24 Jun 2026 10:46:12 -0700 Subject: [PATCH 01/21] refactor(backend): encapsulate throw lifecycle and clean dead code in game.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Game::process_throw to centralize throw→simulation→scoring→state flow. Remove dead room_tx field, simplify add_player, and rename end_ends_or_continue. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/src/game.rs | 102 +++++++++++++++++++++++++------------------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/backend/src/game.rs b/backend/src/game.rs index a9ea466..84516a0 100644 --- a/backend/src/game.rs +++ b/backend/src/game.rs @@ -24,7 +24,13 @@ pub struct Game { stones_red: u8, stones_yellow: u8, last_end_scored: Option<(u8, i32, Option)>, - pub room_tx: Option>, +} + +pub struct ThrowOutcome { + pub trajectory: Vec<(f32, f32, f32)>, + pub end_scored: Option, + pub state_message: ServerMessage, + pub game_over: Option, } impl Game { @@ -42,43 +48,31 @@ impl Game { 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) -> Option { - 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 - } - } - }; + let team = preferred + .filter(|t| self.slot_for(t).is_none()) + .or_else(|| self.first_open_team())?; - 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) + let player = Player { id, team, connected: true }; + *self.slot_for(&team) = Some(player); + Some(team) + } + + fn slot_for(&mut self, team: &Team) -> &mut Option { + match team { + Team::Red => &mut self.red, + Team::Yellow => &mut self.yellow, + } + } + + fn first_open_team(&self) -> Option { + if self.red.is_none() { + Some(Team::Red) + } else if self.yellow.is_none() { + Some(Team::Yellow) } else { None } @@ -171,6 +165,34 @@ impl Game { Ok(path) } + pub fn process_throw( + &mut self, + player_id: &str, + broom_x: f32, + broom_y: f32, + weight: u8, + curl: i8, + friction: f32, + ) -> Result { + let trajectory = self.handle_throw(player_id, broom_x, broom_y, weight, curl, friction)?; + self.finish_simulation(); + + let end_scored = self.take_last_end_scored(); + let state_message = self.game_state_message(); + let game_over = if self.phase == GamePhase::GameComplete { + Some(self.game_over_message()) + } else { + None + }; + + Ok(ThrowOutcome { + trajectory, + end_scored, + state_message, + game_over, + }) + } + pub fn finish_simulation(&mut self) { if self.phase != GamePhase::Simulating { return; @@ -229,10 +251,10 @@ impl Game { self.last_end_scored = Some((self.end, points, scoring_team)); self.phase = GamePhase::EndComplete; - self.end_ends_or_continue(points, scoring_team); + self.advance_end_or_finish(); } - fn end_ends_or_continue(&mut self, _points: i32, _scoring_team: Option) { + fn advance_end_or_finish(&mut self) { let tied = self.scores[0] == self.scores[1]; let after_regulation = self.end >= ENDS; @@ -241,10 +263,6 @@ impl Game { return; } - if after_regulation && tied { - // Extra end - } - self.end += 1; self.stones_red = STONES_PER_TEAM; self.stones_yellow = STONES_PER_TEAM; @@ -311,13 +329,11 @@ pub struct Room { impl Room { pub fn new(id: &str) -> Self { let (tx, _) = tokio::sync::broadcast::channel(256); - let mut room = Self { + Self { id: id.to_string(), game: Game::new(), tx, - }; - room.game.room_tx = Some(room.tx.clone()); - room + } } } -- 2.47.2 From 23a0cafe18ae659973fc29cb9a4fcad051a24789 Mon Sep 17 00:00:00 2001 From: eros Date: Wed, 24 Jun 2026 10:49:56 -0700 Subject: [PATCH 02/21] refactor(backend): split handle_socket and make rooms map async-safe Replace std::sync::Mutex with tokio::sync::Mutex; extract try_join_room, register_player, spawn_forwarder, broadcast_room_state, spawn_message_handler, remove_player. Use Game::process_throw in the message handler. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/src/main.rs | 211 ++++++++++++++++++++++++++------------------ 1 file changed, 124 insertions(+), 87 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index a7f42fa..0c2bd9c 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -13,30 +13,35 @@ 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 std::sync::Arc; +use tokio::sync::Mutex; use tracing::{info, warn}; use uuid::Uuid; -use crate::game::{GamePhase, Room}; +use crate::game::{Room, ThrowOutcome}; use crate::protocol::*; +type WsSender = futures_util::stream::SplitSink; +type WsReceiver = futures_util::stream::SplitStream; + #[derive(Clone)] struct AppState { - rooms: Arc>>>>, + rooms: Arc>>>>, } impl AppState { fn new() -> Self { - Self { rooms: Arc::new(Mutex::new(HashMap::new())) } + Self { + rooms: Arc::new(Mutex::new(HashMap::new())), + } } - fn get_or_create_room(&self, room_id: &str) -> Arc> { - let mut rooms = self.rooms.lock().unwrap(); + async fn get_or_create_room(&self, room_id: &str) -> Arc> { + let mut rooms = self.rooms.lock().await; if let Some(room) = rooms.get(room_id).cloned() { return room; } - let room = Arc::new(TokioMutex::new(Room::new(room_id))); + let room = Arc::new(Mutex::new(Room::new(room_id))); rooms.insert(room_id.to_string(), room.clone()); room } @@ -81,7 +86,7 @@ async fn health() -> impl IntoResponse { async fn new_room(State(state): State>) -> impl IntoResponse { let room_id = generate_room_code(); - state.get_or_create_room(&room_id); + state.get_or_create_room(&room_id).await; (StatusCode::OK, axum::Json(NewRoomResponse { room: room_id })) } @@ -93,50 +98,78 @@ async fn ws_handler( ws.on_upgrade(move |socket| handle_socket(socket, state, query.room, query.team)) } -async fn handle_socket(socket: WebSocket, state: Arc, room_id: String, preferred_team: Option) { - 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(); +async fn handle_socket( + socket: WebSocket, + state: Arc, + room_id: String, + preferred_team: Option, +) { + let room = state.get_or_create_room(&room_id).await; + let (mut sender, receiver) = socket.split(); - 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; - } + if try_join_room(&room).await.is_err() { + 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 player_id = Uuid::new_v4().to_string(); + let team = register_player(&room, &player_id, preferred_team).await; - let joined_msg = serde_json::to_string(&ServerMessage::Joined { + let joined = serde_json::to_string(&ServerMessage::Joined { room: room_id.clone(), team, - }).unwrap(); - let _ = sender.send(Message::Text(Utf8Bytes::from(joined_msg))).await; + }) + .unwrap(); + let _ = sender.send(Message::Text(Utf8Bytes::from(joined))).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 tx = { room.lock().await.tx.clone() }; - let send_task = tokio::spawn(async move { + let send_task = spawn_forwarder(sender, tx.subscribe()); + broadcast_room_state(&room, &tx).await; + + let recv_task = spawn_message_handler(room.clone(), player_id.clone(), tx, receiver); + + tokio::select! { + _ = send_task => {} + _ = recv_task => {} + } + + remove_player(&room, &player_id).await; +} + +async fn try_join_room(room: &Arc>) -> Result<(), ()> { + let room_guard = room.lock().await; + if room_guard.game.can_start() { + return Err(()); + } + Ok(()) +} + +async fn register_player( + room: &Arc>, + player_id: &str, + preferred_team: Option, +) -> Team { + let mut room_guard = room.lock().await; + let team = room_guard + .game + .add_player(player_id.to_string(), preferred_team) + .unwrap_or(Team::Red); + if room_guard.game.can_start() { + room_guard.game.start(); + } + team +} + +fn spawn_forwarder( + mut sender: WsSender, + mut rx: tokio::sync::broadcast::Receiver, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { loop { match rx.recv().await { Ok(msg) => { @@ -151,49 +184,59 @@ async fn handle_socket(socket: WebSocket, state: Arc, room_id: String, 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() }); +async fn broadcast_room_state( + room: &Arc>, + tx: &tokio::sync::broadcast::Sender, +) { + let room_guard = room.lock().await; + let msg = if room_guard.game.can_start() { + room_guard.game.game_state_message() + } else { + ServerMessage::Waiting { + message: "Waiting for other player".to_string(), } - } + }; + let _ = tx.send(msg); +} - let recv_room = room_arc.clone(); - let recv_id = player_id_for_recv; - let recv_task = tokio::spawn(async move { +fn spawn_message_handler( + room: Arc>, + player_id: String, + tx: tokio::sync::broadcast::Sender, + mut receiver: WsReceiver, +) -> tokio::task::JoinHandle<()> { + 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 = 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 mut room_guard = room.lock().await; + match room_guard + .game + .process_throw(&player_id, broom_x, broom_y, weight, curl, friction) + { + Ok(ThrowOutcome { + trajectory, + end_scored, + state_message, + game_over, + }) => { + let _ = tx.send(ServerMessage::Trajectory { path: trajectory }); + if let Some(scored) = end_scored { + let _ = tx.send(scored); } - 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(); + let _ = tx.send(state_message); + if let Some(over) = game_over { + let _ = tx.send(over); } } Err(e) => { - room.tx.send(ServerMessage::Error { message: e }).ok(); + let _ = tx.send(ServerMessage::Error { message: e }); } } } @@ -202,16 +245,10 @@ async fn handle_socket(socket: WebSocket, state: Arc, room_id: String, } } } - }); - - 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); - } + }) +} + +async fn remove_player(room: &Arc>, player_id: &str) { + let mut room_guard = room.lock().await; + room_guard.game.remove_player(player_id); } -- 2.47.2 From ef5349dfe3127adeea931199de72a0e5c97d67d5 Mon Sep 17 00:00:00 2001 From: eros Date: Wed, 24 Jun 2026 10:51:37 -0700 Subject: [PATCH 03/21] refactor(frontend): introduce DrawableStone interface and use in renderer Replace the ad-hoc StoneState | {...} union in drawStone/draw with a named DrawableStone type. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/protocol.ts | 7 +++++++ frontend/src/renderer.ts | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/src/protocol.ts b/frontend/src/protocol.ts index 68aa4ae..ebee7f3 100644 --- a/frontend/src/protocol.ts +++ b/frontend/src/protocol.ts @@ -26,6 +26,13 @@ export interface StoneState { active: boolean } +export interface DrawableStone { + x: number + y: number + rotation: number + team: Team +} + export interface ClientThrowMessage { type: 'throw' broom_x: number diff --git a/frontend/src/renderer.ts b/frontend/src/renderer.ts index 7b0155f..6403bef 100644 --- a/frontend/src/renderer.ts +++ b/frontend/src/renderer.ts @@ -9,8 +9,8 @@ import { STONE_RADIUS, TWELVE_FT_RADIUS, HOUSE_CENTER, + type DrawableStone, type StoneState, - type Team, } from './protocol' export interface Renderer { @@ -23,7 +23,7 @@ export interface Renderer { stones: StoneState[] broom: { x: number; y: number } | null animating: boolean - activeStonePos: { x: number; y: number; rotation: number; team: Team } | null + activeStonePos: DrawableStone | null }) => void } @@ -102,7 +102,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { } } - const drawStone = (stone: StoneState | { x: number; y: number; rotation: number; team: Team }) => { + const drawStone = (stone: DrawableStone) => { const c = worldToScreen(stone.x, stone.y) const r = STONE_RADIUS * scale() const color = stone.team === 'red' ? '#d93025' : '#f9ab00' @@ -129,7 +129,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { stones: StoneState[] broom: { x: number; y: number } | null animating: boolean - activeStonePos: { x: number; y: number; rotation: number; team: Team } | null + activeStonePos: DrawableStone | null }) => { ctx.clearRect(0, 0, window.innerWidth, window.innerHeight) -- 2.47.2 From 0ca0f0e3ffbeef5f3c616986afe0cb80fee36f0c Mon Sep 17 00:00:00 2001 From: eros Date: Wed, 24 Jun 2026 10:55:45 -0700 Subject: [PATCH 04/21] refactor(frontend): centralize game state in GameModel Extract mutable state from game.ts into a dedicated GameModel class to clarify data flow and state transitions. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/game-model.ts | 114 ++++++++++++++++++++++++++++ frontend/src/game.ts | 151 ++++++++----------------------------- 2 files changed, 146 insertions(+), 119 deletions(-) create mode 100644 frontend/src/game-model.ts diff --git a/frontend/src/game-model.ts b/frontend/src/game-model.ts new file mode 100644 index 0000000..afd74d0 --- /dev/null +++ b/frontend/src/game-model.ts @@ -0,0 +1,114 @@ +import { HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type StoneState, type Team } from './protocol' +import { trimPathToStartAtHogLine } from './game-helpers' + +export interface GameModelState { + end: number + scores: number[] + hammer: Team + turnTeam: Team + myTeam: Team | null + phase: Phase + stones: StoneState[] + animating: boolean +} + +export class GameModel { + state: GameModelState = { + end: 1, + scores: [0, 0], + hammer: 'red', + turnTeam: 'red', + myTeam: null, + phase: 'waiting', + stones: [], + animating: false, + } + + pendingStones: StoneState[] = [] + broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y } + isDragging = false + + private activePath: [number, number, number][] = [] + private animationStartTime = 0 + private animationTeam: Team = 'red' + + setMyTeam(team: Team): void { + this.state.myTeam = team + } + + setWaiting(): void { + this.state.phase = 'waiting' + } + + updateGameState(msg: ServerGameStateMessage): void { + const reset = msg.phase === 'waiting' || msg.end !== this.state.end + if (reset) { + this.state.stones = [] + this.pendingStones = [] + } + + if (this.state.animating) { + this.pendingStones = msg.stones + } else { + this.state.stones = msg.stones + } + + this.state = { + ...this.state, + end: msg.end, + scores: msg.scores, + hammer: msg.hammer, + turnTeam: msg.turn_team, + phase: msg.phase, + } + } + + startTrajectory(path: [number, number, number][], team: Team): void { + this.activePath = trimPathToStartAtHogLine(path) + this.state.animating = this.activePath.length > 1 + this.animationStartTime = performance.now() + this.animationTeam = team + this.pendingStones = [] + } + + tick(now: number): DrawableStone | null { + if (!this.state.animating || this.activePath.length <= 1) { + return null + } + + const elapsed = (now - this.animationStartTime) / 1000 + const total = this.activePath[this.activePath.length - 1][2] + if (elapsed >= total) { + this.state.animating = false + if (this.pendingStones.length > 0) { + this.state.stones = this.pendingStones + this.pendingStones = [] + } + return null + } + + let i = 0 + while (i + 1 < this.activePath.length && this.activePath[i + 1][2] < elapsed) i++ + const p0 = this.activePath[i] + const p1 = this.activePath[i + 1] ?? p0 + const t0 = this.activePath[Math.max(i - 1, 0)] + const t2 = this.activePath[Math.min(i + 2, this.activePath.length - 1)] + const dt = p1[2] - p0[2] + const t = dt > 0 ? (elapsed - p0[2]) / dt : 0 + const x = p0[0] + (p1[0] - p0[0]) * t + const y = p0[1] + (p1[1] - p0[1]) * t + const dx = t2[0] - t0[0] + const dy = t2[1] - t0[1] + const rotation = Math.atan2(dy, dx) * 2 + return { x, y, rotation, team: this.animationTeam } + } + + get isMyTurn(): boolean { + return Boolean( + this.state.myTeam && + this.state.turnTeam === this.state.myTeam && + this.state.phase === 'playing' && + !this.state.animating, + ) + } +} diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 630c60a..3586d4e 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -1,19 +1,8 @@ import { connect, sendThrow, type NetCallbacks } from './net' import { createRenderer } from './renderer' import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud' -import { HOUSE_CENTER, HOUSE_RADIUS, type Phase, type StoneState, type Team } from './protocol' -import { trimPathToStartAtHogLine } from './game-helpers' - -interface GameState { - end: number - scores: number[] - hammer: Team - turnTeam: Team - myTeam: Team | null - phase: Phase - stones: StoneState[] - animating: boolean -} +import { HOUSE_CENTER, HOUSE_RADIUS, type Team } from './protocol' +import { GameModel } from './game-model' export function startGame(): void { const app = document.querySelector('#app')! @@ -55,6 +44,8 @@ export function startGame(): void { ` app.appendChild(picker) + const model = new GameModel() + picker.querySelectorAll('.team-btn').forEach((btn) => { btn.addEventListener('click', () => { const team = btn.dataset.team as Team @@ -63,77 +54,31 @@ export function startGame(): void { }) }) - let gameState: GameState = { - end: 1, - scores: [0, 0], - hammer: 'red', - turnTeam: 'red', - myTeam: null, - phase: 'waiting', - stones: [], - animating: false, - } - let pendingStones: StoneState[] = [] - - let broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y } - let isDragging = false - let activePath: [number, number, number][] = [] - let animationStartTime = 0 - let animationTeam: Team = 'red' - const updateControls = () => { - const myTurn = Boolean( - gameState.myTeam && gameState.turnTeam === gameState.myTeam && gameState.phase === 'playing', - ) - velocity.setEnabled(myTurn && !gameState.animating) - curls.setEnabled(myTurn && !gameState.animating) - friction.setEnabled(myTurn && !gameState.animating) - throwBtn.disabled = !myTurn || gameState.animating - if (gameState.phase === 'simulating' || gameState.animating) { - velocity.setEnabled(false) - curls.setEnabled(false) - friction.setEnabled(false) - } - hud.update(gameState) + const myTurn = model.isMyTurn + velocity.setEnabled(myTurn) + curls.setEnabled(myTurn) + friction.setEnabled(myTurn) + throwBtn.disabled = !myTurn + hud.update(model.state) } const render = () => { - let activeStonePos: { x: number; y: number; rotation: number; team: Team } | null = null - if (gameState.animating && activePath.length > 1) { - const elapsed = (performance.now() - animationStartTime) / 1000 - const total = activePath[activePath.length - 1][2] - if (elapsed >= total) { - gameState.animating = false - if (pendingStones.length > 0) { - gameState.stones = pendingStones - pendingStones = [] - } - updateControls() - } else { - let i = 0 - while (i + 1 < activePath.length && activePath[i + 1][2] < elapsed) i++ - const p0 = activePath[i] - const p1 = activePath[i + 1] ?? p0 - const t0 = activePath[Math.max(i - 1, 0)] - const t2 = activePath[Math.min(i + 2, activePath.length - 1)] - const dt = p1[2] - p0[2] - const t = dt > 0 ? (elapsed - p0[2]) / dt : 0 - const x = p0[0] + (p1[0] - p0[0]) * t - const y = p0[1] + (p1[1] - p0[1]) * t - const dx = t2[0] - t0[0] - const dy = t2[1] - t0[1] - const rotation = Math.atan2(dy, dx) * 2 - activeStonePos = { x, y, rotation, team: animationTeam } - } + const wasAnimating = model.state.animating + const activeStonePos = model.tick(performance.now()) + if (wasAnimating && !model.state.animating) { + updateControls() } renderer.draw({ - stones: gameState.stones, + stones: model.state.stones, broom: - gameState.phase === 'playing' && !gameState.animating && gameState.myTeam === gameState.turnTeam - ? broom + model.state.phase === 'playing' && + !model.state.animating && + model.state.myTeam === model.state.turnTeam + ? model.broom : null, - animating: gameState.animating, + animating: model.state.animating, activeStonePos, }) requestAnimationFrame(render) @@ -141,42 +86,19 @@ export function startGame(): void { const callbacks: NetCallbacks = { onJoined: (_roomId, team) => { - gameState.myTeam = team + model.setMyTeam(team) updateControls() }, onWaiting: () => { - gameState.phase = 'waiting' + model.setWaiting() updateControls() }, onGameState: (msg) => { - const reset = msg.phase === 'waiting' || msg.end !== gameState.end - if (reset) { - gameState.stones = [] - pendingStones = [] - } - - if (gameState.animating) { - pendingStones = msg.stones - } else { - gameState.stones = msg.stones - } - - gameState = { - ...gameState, - end: msg.end, - scores: msg.scores, - hammer: msg.hammer, - turnTeam: msg.turn_team, - phase: msg.phase, - } + model.updateGameState(msg) updateControls() }, onTrajectory: (path) => { - activePath = trimPathToStartAtHogLine(path) - gameState.animating = activePath.length > 1 - animationStartTime = performance.now() - animationTeam = gameState.turnTeam - pendingStones = [] + model.startTrajectory(path, model.state.turnTeam) updateControls() }, onEndScored: (end, points, scoringTeam) => { @@ -221,30 +143,21 @@ export function startGame(): void { } const handleStart = (e: Event) => { - if (!isMyDragTurn()) return - isDragging = true + if (!model.isMyTurn) return + model.isDragging = true const pos = getPos(e as TouchEvent) - broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) + model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) } const handleMove = (e: Event) => { - if (!isDragging || !isMyDragTurn()) return + if (!model.isDragging || !model.isMyTurn) return e.preventDefault() const pos = getPos(e as TouchEvent) - broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) + model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) } const handleEnd = () => { - isDragging = false - } - - const isMyDragTurn = () => { - return ( - gameState.myTeam !== null && - gameState.turnTeam === gameState.myTeam && - gameState.phase === 'playing' && - !gameState.animating - ) + model.isDragging = false } canvas.addEventListener('touchstart', handleStart, { passive: false }) @@ -256,8 +169,8 @@ export function startGame(): void { canvas.addEventListener('mouseleave', handleEnd) throwBtn.addEventListener('click', () => { - if (gameState.myTeam !== gameState.turnTeam || gameState.animating || gameState.phase !== 'playing') return - sendThrow(broom.x, broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction()) + if (!model.isMyTurn) return + sendThrow(model.broom.x, model.broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction()) }) updateControls() -- 2.47.2 From 0ef0dc3d78bf0c43fc09d7bb1e517395a03e4a6b Mon Sep 17 00:00:00 2001 From: eros Date: Wed, 24 Jun 2026 10:58:35 -0700 Subject: [PATCH 05/21] refactor(backend): remove remaining dead code and unused constants Drop unused Game methods, Room.id field, and backend-only sheet ring constants that duplicated frontend definitions. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/src/game.rs | 29 +---------------------------- backend/src/protocol.rs | 4 ---- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/backend/src/game.rs b/backend/src/game.rs index 84516a0..785a63c 100644 --- a/backend/src/game.rs +++ b/backend/src/game.rs @@ -94,15 +94,6 @@ impl Game { 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() } @@ -131,12 +122,6 @@ impl Game { Some(&p.id) } - pub fn current_team_for_player(&self, player_id: &str) -> Option { - 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, String> { let current_id = self.current_player_id().ok_or("No current player")?; if current_id != player_id { @@ -201,10 +186,6 @@ impl Game { 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 { @@ -273,10 +254,6 @@ impl Game { self.phase = GamePhase::Playing; } - pub fn phase(&self) -> GamePhase { - self.phase - } - pub fn take_last_end_scored(&mut self) -> Option { let msg = self.last_end_scored.map(|(end, points, scoring_team)| { ServerMessage::EndScored { end, points, scoring_team } @@ -318,19 +295,15 @@ impl Game { } } -pub type RoomId = String; - pub struct Room { - pub id: RoomId, pub game: Game, pub tx: tokio::sync::broadcast::Sender, } impl Room { - pub fn new(id: &str) -> Self { + pub fn new(_id: &str) -> Self { let (tx, _) = tokio::sync::broadcast::channel(256); Self { - id: id.to_string(), game: Game::new(), tx, } diff --git a/backend/src/protocol.rs b/backend/src/protocol.rs index 4589e91..925b216 100644 --- a/backend/src/protocol.rs +++ b/backend/src/protocol.rs @@ -13,10 +13,6 @@ 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; -- 2.47.2 From 2f68325801de7e4bf1f1172303f61c642402e14c Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:06 -0700 Subject: [PATCH 06/21] refactor(backend): multi-stone StoneTrajectory on protocol wire Clients send team on throw; joined no longer assigns a fixed slot team. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/src/protocol.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/src/protocol.rs b/backend/src/protocol.rs index 925b216..decf70b 100644 --- a/backend/src/protocol.rs +++ b/backend/src/protocol.rs @@ -55,6 +55,7 @@ impl fmt::Display for Team { #[serde(tag = "type", rename_all = "snake_case")] pub enum ClientMessage { Throw { + team: Team, broom_x: f32, broom_y: f32, weight: u8, @@ -71,7 +72,7 @@ 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 }, + Joined { room: String }, Waiting { message: String }, GameState { end: u8, @@ -82,7 +83,7 @@ pub enum ServerMessage { phase: Phase, }, Trajectory { - path: Vec<(f32, f32, f32)>, + paths: Vec, }, EndScored { end: u8, points: i32, scoring_team: Option }, GameOver { @@ -104,6 +105,12 @@ pub enum Phase { GameComplete, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoneTrajectory { + pub stone_id: u32, + pub path: Vec<(f32, f32, f32)>, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoneState { pub id: u32, @@ -113,10 +120,3 @@ pub struct StoneState { pub rotation: f32, pub active: bool, } - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Player { - pub id: String, - pub team: Team, - pub connected: bool, -} -- 2.47.2 From 5eeaf8929d1340c2978c3d8a3e956e95a64966aa Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:06 -0700 Subject: [PATCH 07/21] feat(physics): record trajectories for every moving stone Collision playback needs all stones sampled on a shared t=0 release clock. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/src/physics.rs | 126 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 11 deletions(-) diff --git a/backend/src/physics.rs b/backend/src/physics.rs index 4142952..625c342 100644 --- a/backend/src/physics.rs +++ b/backend/src/physics.rs @@ -107,7 +107,7 @@ impl PhysicsWorld { weight: u8, curl: i8, friction: f32, - ) -> Result, String> { + ) -> Result, 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); @@ -132,7 +132,7 @@ impl PhysicsWorld { vy: f32, curl_sign: i8, damping_mult: f32, - ) -> Result, String> { + ) -> Result, String> { let id = self.next_stone_id; self.next_stone_id += 1; @@ -161,15 +161,31 @@ impl PhysicsWorld { self.simulate_until_rest(id) } - fn simulate_until_rest(&mut self, thrown_id: u32) -> Result, String> { - let mut path: Vec<(f32, f32, f32)> = Vec::new(); + fn simulate_until_rest(&mut self, thrown_id: u32) -> Result, String> { + // All paths share the thrown stone's release instant as t=0. This keeps the + // frontend's existing trajectory helpers (which expect the thrown stone to + // start at x=0, y=HACK_Y with t=0) working unchanged while also giving every + // other stone a consistent timeline. 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)); + // Pre-allocate a path buffer for every stone currently in the world. + let mut paths: Vec<(u32, RigidBodyHandle, Vec<(f32, f32, f32)>)> = self + .stone_handles + .iter() + .map(|(id, handle, _, _)| (*id, *handle, Vec::new())) + .collect(); + + // Record the initial sample at t=0 for every stone. + for (id, handle, path) in &mut paths { + if let Some(body) = self.bodies.get(*handle) { + let pos = body.translation(); + path.push((pos.x, pos.y, time)); + } else { + // Body missing for an tracked stone; this should not happen. + return Err(format!("stone {} has no rigid body", id)); + } } loop { @@ -180,9 +196,11 @@ impl PhysicsWorld { 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)); + for (_, handle, path) in &mut paths { + if let Some(body) = self.bodies.get(*handle) { + let pos = body.translation(); + path.push((pos.x, pos.y, time)); + } } } @@ -193,7 +211,27 @@ impl PhysicsWorld { self.prune_out_of_play(); - Ok(path) + // The thrown stone is released at (0.0, HACK_Y). Shift every path in time so + // that t=0 corresponds to that release instant. Because we already started + // sampling at the release instant, the first sample time is 0.0 and no shift + // is required; this comment documents the invariant. + let thrown_first_t = paths + .iter() + .find(|(id, _, _)| *id == thrown_id) + .and_then(|(_, _, path)| path.first().map(|(_, _, t)| *t)) + .unwrap_or(0.0); + + Ok(paths + .into_iter() + .map(|(id, _, mut path)| { + if thrown_first_t != 0.0 { + for (_, _, t) in &mut path { + *t -= thrown_first_t; + } + } + StoneTrajectory { stone_id: id, path } + }) + .collect()) } // Rotate each stone's velocity slightly based on its selected curl direction. @@ -377,4 +415,70 @@ mod tests { let stones = world.current_stones(); assert!(stones.is_empty(), "stones short of the hog line should be pruned"); } + + #[test] + fn collision_records_trajectories_for_both_stones() { + // Place a stationary stone on the center line and throw a second stone + // straight at it so they collide. Both stones must have sampled paths. + let mut world = PhysicsWorld::new(); + + // First stone: place it far enough up-sheet to stay in play after impact. + world + .throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 0, 1.0) + .unwrap(); + let first_id = world.next_stone_id - 1; + + // Second stone: aimed directly at the first stone's final position. + let target_y = final_y(&world, first_id); + let target_x = final_x(&world, first_id); + world + .throw(Team::Yellow, target_x, target_y, 10, 0, 1.0) + .unwrap(); + let second_id = world.next_stone_id - 1; + + // Re-run the collision throw and capture trajectories. + let mut world = PhysicsWorld::new(); + world + .throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 0, 1.0) + .unwrap(); + let first_id = world.next_stone_id - 1; + let target_y = final_y(&world, first_id); + let target_x = final_x(&world, first_id); + let trajectories = world + .throw(Team::Yellow, target_x, target_y, 10, 0, 1.0) + .unwrap(); + + let by_id: std::collections::HashMap> = trajectories + .into_iter() + .map(|st| (st.stone_id, st.path)) + .collect(); + + assert!( + by_id.contains_key(&first_id), + "trajectories should contain the first stone (id={})", + first_id + ); + assert!( + by_id.contains_key(&second_id), + "trajectories should contain the thrown stone (id={})", + second_id + ); + + let first_path = by_id.get(&first_id).unwrap(); + let second_path = by_id.get(&second_id).unwrap(); + assert!( + first_path.len() > 1, + "first stone path should have multiple samples, got {}", + first_path.len() + ); + assert!( + second_path.len() > 1, + "thrown stone path should have multiple samples, got {}", + second_path.len() + ); + + // Both paths should share the same t=0 reference (the thrown stone's release). + assert_eq!(first_path[0].2, 0.0, "first stone path should start at t=0"); + assert_eq!(second_path[0].2, 0.0, "thrown stone path should start at t=0"); + } } -- 2.47.2 From 0e08e21c9b008127dcbd7200c3d6a8e696a7a293 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:06 -0700 Subject: [PATCH 08/21] refactor(backend): team-based throws without player slots Anyone identifying as the turn team can throw; room slots for 1v1 removed. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/src/game.rs | 118 +++++++++++++------------------------------- 1 file changed, 35 insertions(+), 83 deletions(-) diff --git a/backend/src/game.rs b/backend/src/game.rs index 785a63c..5888960 100644 --- a/backend/src/game.rs +++ b/backend/src/game.rs @@ -12,8 +12,6 @@ pub enum GamePhase { } pub struct Game { - red: Option, - yellow: Option, phase: GamePhase, end: u8, scores: [i32; 2], @@ -27,7 +25,7 @@ pub struct Game { } pub struct ThrowOutcome { - pub trajectory: Vec<(f32, f32, f32)>, + pub trajectory: Vec, pub end_scored: Option, pub state_message: ServerMessage, pub game_over: Option, @@ -36,8 +34,6 @@ pub struct ThrowOutcome { impl Game { pub fn new() -> Self { Self { - red: None, - yellow: None, phase: GamePhase::Waiting, end: 1, scores: [0, 0], @@ -51,57 +47,7 @@ impl Game { } } - pub fn add_player(&mut self, id: String, preferred: Option) -> Option { - let team = preferred - .filter(|t| self.slot_for(t).is_none()) - .or_else(|| self.first_open_team())?; - - let player = Player { id, team, connected: true }; - *self.slot_for(&team) = Some(player); - Some(team) - } - - fn slot_for(&mut self, team: &Team) -> &mut Option { - match team { - Team::Red => &mut self.red, - Team::Yellow => &mut self.yellow, - } - } - - fn first_open_team(&self) -> Option { - if self.red.is_none() { - Some(Team::Red) - } else if self.yellow.is_none() { - Some(Team::Yellow) - } else { - None - } - } - - pub fn remove_player(&mut self, id: &str) -> Option { - 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 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; @@ -114,17 +60,16 @@ impl Game { 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 handle_throw(&mut self, player_id: &str, broom_x: f32, broom_y: f32, weight: u8, curl: i8, friction: f32) -> Result, String> { - let current_id = self.current_player_id().ok_or("No current player")?; - if current_id != player_id { + pub fn handle_throw( + &mut self, + team: Team, + broom_x: f32, + broom_y: f32, + weight: u8, + curl: i8, + friction: f32, + ) -> Result, String> { + if self.turn_team != team { return Err("Not your turn".to_string()); } if self.phase != GamePhase::Playing { @@ -138,7 +83,7 @@ impl Game { } self.active_stones.clear(); - let path = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?; + let trajectory = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?; self.active_stones = self.physics.current_stones(); self.phase = GamePhase::Simulating; @@ -147,19 +92,19 @@ impl Game { Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1), } - Ok(path) + Ok(trajectory) } pub fn process_throw( &mut self, - player_id: &str, + team: Team, broom_x: f32, broom_y: f32, weight: u8, curl: i8, friction: f32, ) -> Result { - let trajectory = self.handle_throw(player_id, broom_x, broom_y, weight, curl, friction)?; + let trajectory = self.handle_throw(team, broom_x, broom_y, weight, curl, friction)?; self.finish_simulation(); let end_scored = self.take_last_end_scored(); @@ -223,7 +168,7 @@ impl Game { Team::Red => 0, Team::Yellow => 1, }; - self.scores[team_idx] += points as i32; + self.scores[team_idx] += points; self.hammer = team.other(); } else { points = 0; @@ -315,29 +260,36 @@ 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)); + fn starts_in_waiting_phase() { + let game = Game::new(); + assert!(matches!(game.phase, GamePhase::Waiting)); } #[test] - fn prefers_yellow_when_requested_and_free() { + fn starts_when_called() { let mut game = Game::new(); - assert_eq!(game.add_player("p1".into(), Some(Team::Yellow)), Some(Team::Yellow)); + game.start(); + assert!(matches!(game.phase, GamePhase::Playing)); + assert_eq!(game.end, 1); + assert_eq!(game.scores, [0, 0]); } #[test] - fn falls_back_when_preferred_taken() { + fn rejects_throw_for_wrong_team() { 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)); + game.start(); + let turn = game.turn_team; + let wrong = turn.other(); + let result = game.handle_throw(wrong, 0.5, 38.7, 7, 1, 1.0); + assert!(result.is_err()); } #[test] - fn legacy_order_without_preference() { + fn accepts_throw_for_turn_team() { 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); + game.start(); + let turn = game.turn_team; + let result = game.handle_throw(turn, 0.5, 38.7, 7, 1, 1.0); + assert!(result.is_ok()); } } -- 2.47.2 From a4e848c849a1debca11a183992be1365a21b0ba9 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:06 -0700 Subject: [PATCH 09/21] refactor(backend): allow multi-client rooms and drop room-full Anyone may join and watch/throw; game auto-starts on first connection. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/src/main.rs | 75 +++++++++------------------------------------ 1 file changed, 15 insertions(+), 60 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index 0c2bd9c..caa36ec 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -50,7 +50,6 @@ impl AppState { #[derive(Deserialize)] struct RoomQuery { room: String, - team: Option, } #[derive(Serialize)] @@ -95,74 +94,43 @@ async fn ws_handler( Query(query): Query, State(state): State>, ) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_socket(socket, state, query.room, query.team)) + ws.on_upgrade(move |socket| handle_socket(socket, state, query.room)) } async fn handle_socket( socket: WebSocket, state: Arc, room_id: String, - preferred_team: Option, ) { let room = state.get_or_create_room(&room_id).await; let (mut sender, receiver) = socket.split(); - if try_join_room(&room).await.is_err() { - 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; + { + let mut room_guard = room.lock().await; + if matches!(room_guard.game.game_state_message(), ServerMessage::GameState { phase: Phase::Waiting, .. }) { + room_guard.game.start(); + } } - let player_id = Uuid::new_v4().to_string(); - let team = register_player(&room, &player_id, preferred_team).await; - let joined = serde_json::to_string(&ServerMessage::Joined { room: room_id.clone(), - team, }) .unwrap(); - let _ = sender.send(Message::Text(Utf8Bytes::from(joined))).await; + if sender.send(Message::Text(Utf8Bytes::from(joined))).await.is_err() { + return; + } let tx = { room.lock().await.tx.clone() }; let send_task = spawn_forwarder(sender, tx.subscribe()); broadcast_room_state(&room, &tx).await; - let recv_task = spawn_message_handler(room.clone(), player_id.clone(), tx, receiver); + let recv_task = spawn_message_handler(room.clone(), tx, receiver); tokio::select! { _ = send_task => {} _ = recv_task => {} } - - remove_player(&room, &player_id).await; -} - -async fn try_join_room(room: &Arc>) -> Result<(), ()> { - let room_guard = room.lock().await; - if room_guard.game.can_start() { - return Err(()); - } - Ok(()) -} - -async fn register_player( - room: &Arc>, - player_id: &str, - preferred_team: Option, -) -> Team { - let mut room_guard = room.lock().await; - let team = room_guard - .game - .add_player(player_id.to_string(), preferred_team) - .unwrap_or(Team::Red); - if room_guard.game.can_start() { - room_guard.game.start(); - } - team } fn spawn_forwarder( @@ -192,33 +160,25 @@ async fn broadcast_room_state( tx: &tokio::sync::broadcast::Sender, ) { let room_guard = room.lock().await; - let msg = if room_guard.game.can_start() { - room_guard.game.game_state_message() - } else { - ServerMessage::Waiting { - message: "Waiting for other player".to_string(), - } - }; - let _ = tx.send(msg); + let _ = tx.send(room_guard.game.game_state_message()); } fn spawn_message_handler( room: Arc>, - player_id: String, tx: tokio::sync::broadcast::Sender, mut receiver: WsReceiver, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { while let Some(Ok(msg)) = receiver.next().await { - let Message::Text(text) = msg else { continue; }; + let Message::Text(text) = msg else { continue }; let text_ref = text.as_str(); let parsed: Result = serde_json::from_str(text_ref); match parsed { - Ok(ClientMessage::Throw { broom_x, broom_y, weight, curl, friction }) => { + Ok(ClientMessage::Throw { team, broom_x, broom_y, weight, curl, friction }) => { let mut room_guard = room.lock().await; match room_guard .game - .process_throw(&player_id, broom_x, broom_y, weight, curl, friction) + .process_throw(team, broom_x, broom_y, weight, curl, friction) { Ok(ThrowOutcome { trajectory, @@ -226,7 +186,7 @@ fn spawn_message_handler( state_message, game_over, }) => { - let _ = tx.send(ServerMessage::Trajectory { path: trajectory }); + let _ = tx.send(ServerMessage::Trajectory { paths: trajectory }); if let Some(scored) = end_scored { let _ = tx.send(scored); } @@ -247,8 +207,3 @@ fn spawn_message_handler( } }) } - -async fn remove_player(room: &Arc>, player_id: &str) { - let mut room_guard = room.lock().await; - room_guard.game.remove_player(player_id); -} -- 2.47.2 From 722e6090d1b26a6355e3554453f8bb7094948e9b Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:06 -0700 Subject: [PATCH 10/21] refactor(frontend): multi-path trajectory protocol and team on throw Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/net.ts | 35 ++++++++++++++++++++++++++--------- frontend/src/protocol.ts | 9 +++++++-- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/frontend/src/net.ts b/frontend/src/net.ts index ecd30a1..6374acf 100644 --- a/frontend/src/net.ts +++ b/frontend/src/net.ts @@ -1,5 +1,6 @@ import type { ServerGameStateMessage, + ServerStoneTrajectory, ServerMessageTyped as ServerMessage, Team, } from './protocol' @@ -13,10 +14,10 @@ function resolveWsUrl(): string { } export interface NetCallbacks { - onJoined: (room: string, team: Team) => void + onJoined: (room: string) => void onWaiting: (message: string) => void onGameState: (msg: ServerGameStateMessage) => void - onTrajectory: (path: [number, number, number][]) => void + onTrajectory: (paths: ServerStoneTrajectory[]) => void onEndScored: (end: number, points: number, scoringTeam: Team | null) => void onGameOver: (scores: number[], winner: Team | null) => void onError: (message: string) => void @@ -25,10 +26,9 @@ export interface NetCallbacks { let socket: WebSocket | null = null -export function connect(room: string, team: Team | null, callbacks: NetCallbacks): void { +export function connect(room: string, callbacks: NetCallbacks): void { if (socket) return - const teamParam = team ? `&team=${encodeURIComponent(team)}` : '' - const url = `${resolveWsUrl()}?room=${encodeURIComponent(room)}${teamParam}` + const url = `${resolveWsUrl()}?room=${encodeURIComponent(room)}` const ws = new WebSocket(url) ws.onopen = () => { @@ -42,7 +42,7 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks switch (msg.type) { case 'joined': - callbacks.onJoined(msg.room, msg.team) + callbacks.onJoined(msg.room) break case 'waiting': callbacks.onWaiting(msg.message) @@ -51,7 +51,7 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks callbacks.onGameState(msg) break case 'trajectory': - callbacks.onTrajectory(msg.path) + callbacks.onTrajectory(msg.paths) break case 'end_scored': callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null) @@ -76,7 +76,24 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks ws.onerror = () => {} } -export function sendThrow(broomX: number, broomY: number, weight: number, curl: number, friction: number): void { +export function sendThrow( + team: Team, + broomX: number, + broomY: number, + weight: number, + curl: number, + friction: number, +): void { if (!socket || socket.readyState !== WebSocket.OPEN) return - socket.send(JSON.stringify({ type: 'throw', broom_x: broomX, broom_y: broomY, weight, curl, friction })) + socket.send( + JSON.stringify({ + type: 'throw', + team, + broom_x: broomX, + broom_y: broomY, + weight, + curl, + friction, + }), + ) } diff --git a/frontend/src/protocol.ts b/frontend/src/protocol.ts index ebee7f3..7e74f8c 100644 --- a/frontend/src/protocol.ts +++ b/frontend/src/protocol.ts @@ -35,6 +35,7 @@ export interface DrawableStone { export interface ClientThrowMessage { type: 'throw' + team: Team broom_x: number broom_y: number weight: number @@ -45,7 +46,6 @@ export interface ClientThrowMessage { export interface ServerJoinedMessage { type: 'joined' room: string - team: Team } export interface ServerWaitingMessage { @@ -63,9 +63,14 @@ export interface ServerGameStateMessage { phase: Phase } +export interface ServerStoneTrajectory { + stone_id: number + path: [number, number, number][] +} + export interface ServerTrajectoryMessage { type: 'trajectory' - path: [number, number, number][] + paths: ServerStoneTrajectory[] } export interface ServerEndScoredMessage { -- 2.47.2 From c28bf32eb8434e8f9fe7f1f35312c10058d1e229 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:06 -0700 Subject: [PATCH 11/21] feat(frontend): animate all stone trajectories on one clock Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/game-model.ts | 106 ++++++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 19 deletions(-) diff --git a/frontend/src/game-model.ts b/frontend/src/game-model.ts index afd74d0..86ffb3a 100644 --- a/frontend/src/game-model.ts +++ b/frontend/src/game-model.ts @@ -1,4 +1,4 @@ -import { HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type StoneState, type Team } from './protocol' +import { HOG_LINE_Y, HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type ServerStoneTrajectory, type StoneState, type Team } from './protocol' import { trimPathToStartAtHogLine } from './game-helpers' export interface GameModelState { @@ -27,10 +27,10 @@ export class GameModel { pendingStones: StoneState[] = [] broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y } isDragging = false + isPanning = false - private activePath: [number, number, number][] = [] + private activePaths = new Map() private animationStartTime = 0 - private animationTeam: Team = 'red' setMyTeam(team: Team): void { this.state.myTeam = team @@ -63,36 +63,104 @@ export class GameModel { } } - startTrajectory(path: [number, number, number][], team: Team): void { - this.activePath = trimPathToStartAtHogLine(path) - this.state.animating = this.activePath.length > 1 + startTrajectory(paths: ServerStoneTrajectory[]): void { + const existingIds = new Set(this.state.stones.map((s) => s.id)) + + let thrownId: number | null = null + for (const { stone_id } of paths) { + if (!existingIds.has(stone_id)) { + thrownId = stone_id + break + } + } + if (thrownId === null && paths.length > 0) { + thrownId = paths[0].stone_id + } + + let tRef = 0 + if (thrownId !== null) { + const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? [] + if (thrownPath.length >= 2) { + const idx = thrownPath.findIndex(([, y]) => y >= HOG_LINE_Y) + if (idx >= 0) { + const start = Math.max(0, idx - 1) + tRef = thrownPath[start][2] + } + } + } + + const pathMap = new Map() + for (const { stone_id, path } of paths) { + if (stone_id === thrownId) { + pathMap.set(stone_id, trimPathToStartAtHogLine(path)) + } else { + const shifted = path + .map(([x, y, t]) => [x, y, t - tRef] as [number, number, number]) + .filter(([, , t]) => t >= 0) + pathMap.set(stone_id, shifted) + } + } + + this.activePaths = pathMap + this.state.animating = Array.from(pathMap.values()).some((p) => p.length > 1) this.animationStartTime = performance.now() - this.animationTeam = team this.pendingStones = [] } - tick(now: number): DrawableStone | null { - if (!this.state.animating || this.activePath.length <= 1) { - return null + tick(now: number): DrawableStone[] { + if (!this.state.animating) { + return [] } const elapsed = (now - this.animationStartTime) / 1000 - const total = this.activePath[this.activePath.length - 1][2] - if (elapsed >= total) { + const maxTotal = Math.max( + 0, + ...Array.from(this.activePaths.values()).map((p) => (p.length > 0 ? p[p.length - 1][2] : 0)), + ) + + if (elapsed >= maxTotal) { this.state.animating = false if (this.pendingStones.length > 0) { this.state.stones = this.pendingStones this.pendingStones = [] } - return null + return [] + } + + const result: DrawableStone[] = [] + for (const [stoneId, path] of this.activePaths) { + const pos = this.interpolatePath(path, elapsed) + if (!pos) continue + const team = this.state.stones.find((s) => s.id === stoneId)?.team ?? this.state.turnTeam + result.push({ ...pos, team }) + } + return result + } + + private interpolatePath( + path: [number, number, number][], + elapsed: number, + ): { x: number; y: number; rotation: number } | null { + if (path.length === 0) return null + if (path.length === 1) { + const [x, y] = path[0] + return { x, y, rotation: 0 } + } + + if (elapsed >= path[path.length - 1][2]) { + const last = path[path.length - 1] + const prev = path[path.length - 2] + const dx = last[0] - prev[0] + const dy = last[1] - prev[1] + return { x: last[0], y: last[1], rotation: Math.atan2(dy, dx) * 2 } } let i = 0 - while (i + 1 < this.activePath.length && this.activePath[i + 1][2] < elapsed) i++ - const p0 = this.activePath[i] - const p1 = this.activePath[i + 1] ?? p0 - const t0 = this.activePath[Math.max(i - 1, 0)] - const t2 = this.activePath[Math.min(i + 2, this.activePath.length - 1)] + while (i + 1 < path.length && path[i + 1][2] < elapsed) i++ + const p0 = path[i] + const p1 = path[i + 1] ?? p0 + const t0 = path[Math.max(i - 1, 0)] + const t2 = path[Math.min(i + 2, path.length - 1)] const dt = p1[2] - p0[2] const t = dt > 0 ? (elapsed - p0[2]) / dt : 0 const x = p0[0] + (p1[0] - p0[0]) * t @@ -100,7 +168,7 @@ export class GameModel { const dx = t2[0] - t0[0] const dy = t2[1] - t0[1] const rotation = Math.atan2(dy, dx) * 2 - return { x, y, rotation, team: this.animationTeam } + return { x, y, rotation } } get isMyTurn(): boolean { -- 2.47.2 From 56a7d5abbac7e057bc0a81906c6a3326892e1299 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:06 -0700 Subject: [PATCH 12/21] feat(frontend): free team switch and sheet pan for multi-client play Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/game.ts | 96 +++++++++++++++++++++++++++++--------------- frontend/src/hud.ts | 42 +++++++++++-------- 2 files changed, 89 insertions(+), 49 deletions(-) diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 3586d4e..66ae665 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -13,6 +13,10 @@ export function startGame(): void { const hud = createHud() app.appendChild(hud.root) + if (import.meta.env.DEV) { + ;(window as unknown as { __renderer: typeof renderer }).__renderer = renderer + } + const velocityContainer = hud.velocityControl const curlContainer = hud.curlSelector const frictionContainer = hud.frictionControl @@ -31,30 +35,17 @@ export function startGame(): void { const shareLink = `${window.location.origin}/?room=${room}` hud.setShareLink(shareLink) - const picker = document.createElement('div') - picker.id = 'team-picker' - picker.innerHTML = ` -
-

Choose your team

-
- - -
-
- ` - app.appendChild(picker) - const model = new GameModel() - picker.querySelectorAll('.team-btn').forEach((btn) => { - btn.addEventListener('click', () => { - const team = btn.dataset.team as Team - picker.remove() - connect(room, team, callbacks) - }) - }) + const initialTeam: Team = localStorage.getItem('curltastic-team') === 'yellow' ? 'yellow' : 'red' + model.setMyTeam(initialTeam) + hud.setTeam(initialTeam) + + // connect() is invoked after callbacks is defined below. const updateControls = () => { + const activeTeam = hud.teamSelect.value as Team + model.setMyTeam(activeTeam) const myTurn = model.isMyTurn velocity.setEnabled(myTurn) curls.setEnabled(myTurn) @@ -63,6 +54,11 @@ export function startGame(): void { hud.update(model.state) } + hud.teamSelect.addEventListener('change', () => { + localStorage.setItem('curltastic-team', hud.teamSelect.value) + updateControls() + }) + const render = () => { const wasAnimating = model.state.animating const activeStonePos = model.tick(performance.now()) @@ -85,8 +81,7 @@ export function startGame(): void { } const callbacks: NetCallbacks = { - onJoined: (_roomId, team) => { - model.setMyTeam(team) + onJoined: () => { updateControls() }, onWaiting: () => { @@ -97,8 +92,8 @@ export function startGame(): void { model.updateGameState(msg) updateControls() }, - onTrajectory: (path) => { - model.startTrajectory(path, model.state.turnTeam) + onTrajectory: (paths) => { + model.startTrajectory(paths) updateControls() }, onEndScored: (end, points, scoringTeam) => { @@ -142,22 +137,51 @@ export function startGame(): void { return world } + const isInHouse = (world: { x: number; y: number }) => { + const dx = world.x - HOUSE_CENTER.x + const dy = world.y - HOUSE_CENTER.y + return Math.sqrt(dx * dx + dy * dy) <= HOUSE_RADIUS + } + + let lastPanScreenY = 0 + const handleStart = (e: Event) => { - if (!model.isMyTurn) return - model.isDragging = true const pos = getPos(e as TouchEvent) - model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) + const world = renderer.screenToWorld(pos.x, pos.y) + + if (model.isMyTurn && isInHouse(world)) { + model.isDragging = true + model.broom = constrainBroom(world) + } else { + model.isPanning = true + lastPanScreenY = pos.y + } } const handleMove = (e: Event) => { - if (!model.isDragging || !model.isMyTurn) return - e.preventDefault() - const pos = getPos(e as TouchEvent) - model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) + if (model.isDragging && model.isMyTurn) { + e.preventDefault() + const pos = getPos(e as TouchEvent) + model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) + return + } + + if (model.isPanning) { + e.preventDefault() + const pos = getPos(e as TouchEvent) + const prevWorld = renderer.screenToWorld(pos.x, lastPanScreenY) + const nowWorld = renderer.screenToWorld(pos.x, pos.y) + // Direct-manipulation pan: dragging the sheet down reveals the hog line above. + const deltaY = nowWorld.y - prevWorld.y + const sensitivity = 1.5 + renderer.setViewYOffset(renderer.clampViewYOffset() + deltaY * sensitivity) + lastPanScreenY = pos.y + } } const handleEnd = () => { model.isDragging = false + model.isPanning = false } canvas.addEventListener('touchstart', handleStart, { passive: false }) @@ -170,9 +194,17 @@ export function startGame(): void { throwBtn.addEventListener('click', () => { if (!model.isMyTurn) return - sendThrow(model.broom.x, model.broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction()) + sendThrow( + hud.teamSelect.value as Team, + model.broom.x, + model.broom.y, + velocity.getWeight(), + curls.getSelected(), + friction.getFriction(), + ) }) + connect(room, callbacks) updateControls() render() } diff --git a/frontend/src/hud.ts b/frontend/src/hud.ts index 0d0dd17..65734f2 100644 --- a/frontend/src/hud.ts +++ b/frontend/src/hud.ts @@ -3,17 +3,18 @@ import { velocityToWeight, weightToVelocity } from './game-helpers' export interface Hud { root: HTMLDivElement + teamSelect: HTMLSelectElement velocityControl: HTMLDivElement curlSelector: HTMLDivElement frictionControl: HTMLDivElement throwButton: HTMLButtonElement + setTeam: (team: Team) => void update: (state: { phase: Phase end: number scores: number[] hammer: Team turnTeam: Team - myTeam: Team | null animating: boolean }) => void showToast: (message: string) => void @@ -48,11 +49,19 @@ export function createHud(): Hud { const root = document.createElement('div') root.id = 'hud' root.innerHTML = ` -
-
Red 0 - Yellow 0
-
End 1 · Waiting
-
You: -
-
Hammer: -
+
+
+
+
+
+
Red 0 - Yellow 0
+
End 1 · Waiting
+ +
Hammer: -
+
@@ -66,34 +75,32 @@ export function createHud(): Hud {
-
Waiting for other player
-
+
Waiting
` const scoreEl = root.querySelector('#score')! const endInfoEl = root.querySelector('#end-info')! - const teamEl = root.querySelector('#team')! + const teamSelect = root.querySelector('#team-select')! const hammerEl = root.querySelector('#hammer')! const waitingEl = root.querySelector('#waiting')! return { root, + teamSelect, velocityControl: root.querySelector('#velocity-control')!, curlSelector: root.querySelector('#curl-selector')!, frictionControl: root.querySelector('#friction-control')!, throwButton: root.querySelector('#throw-btn')!, + setTeam: (team) => { + teamSelect.value = team + }, update: (state) => { scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}` const teamNames: Record = { red: 'Red', yellow: 'Yellow' } const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ') endInfoEl.textContent = `End ${state.end} · ${phaseText}` - teamEl.textContent = state.myTeam ? `You: ${teamNames[state.myTeam]}` : 'You: -' hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}` - const waiting = - state.phase === 'waiting' || - (state.phase === 'playing' && state.myTeam !== null && state.turnTeam !== state.myTeam) || - state.animating - waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && waiting) + waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating) }, showToast: (message: string) => { const toast = document.createElement('div') @@ -179,14 +186,15 @@ export function createCurlSelector( ): { getSelected: () => number; setEnabled: (enabled: boolean) => void } { const state = { selected: 1, enabled: true } const options = [ - { value: -1, label: '↶ Left' }, - { value: 1, label: 'Right ↷' }, + { value: -1, label: '↷', ariaLabel: 'Left curl' }, + { value: 1, label: '↶', ariaLabel: 'Right curl' }, ] container.innerHTML = '' for (const opt of options) { const btn = document.createElement('button') btn.className = 'curl-btn' btn.textContent = opt.label + btn.ariaLabel = opt.ariaLabel btn.dataset.curl = String(opt.value) btn.addEventListener('click', () => { if (!state.enabled) return -- 2.47.2 From c37026b0a0f27eee40ebe7680f01a1d35f1f93ba Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:07 -0700 Subject: [PATCH 13/21] feat(frontend): house-centered camera with y-pan offset Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/renderer.ts | 67 ++++++++++++----- frontend/src/style.css | 154 +++++++++++++++++++++------------------ 2 files changed, 134 insertions(+), 87 deletions(-) diff --git a/frontend/src/renderer.ts b/frontend/src/renderer.ts index 6403bef..fcf64cb 100644 --- a/frontend/src/renderer.ts +++ b/frontend/src/renderer.ts @@ -19,19 +19,23 @@ export interface Renderer { setSize: () => void worldToScreen: (x: number, y: number) => { x: number; y: number } screenToWorld: (sx: number, sy: number) => { x: number; y: number } + setViewYOffset: (y: number) => void + clampViewYOffset: () => number draw: (state: { stones: StoneState[] broom: { x: number; y: number } | null animating: boolean - activeStonePos: DrawableStone | null + activeStonePos: DrawableStone[] }) => void } export function createRenderer(canvas: HTMLCanvasElement): Renderer { const ctx = canvas.getContext('2d')! - // Show the area from the hog line (top) to the back line (bottom). - const viewportHeight = BACK_LINE_Y - HOG_LINE_Y - const viewportCenter = { x: 0, y: (HOG_LINE_Y + BACK_LINE_Y) / 2 } + // Default viewport: roughly 14 ft (≈ 4.27 m) wide, centered on the house. + // Height is derived from the screen aspect ratio so the whole sheet is visible. + const viewportWidth = 14.0 / 3.28084 + const viewportCenter = { x: 0, y: HOUSE_CENTER.y } + let viewportYOffset = 0 const setSize = () => { const dpr = window.devicePixelRatio || 1 @@ -45,17 +49,26 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { } const scale = (): number => { - const height = window.innerHeight - return height / viewportHeight + return window.innerWidth / viewportWidth } + const viewportHeight = (): number => { + return window.innerHeight / scale() + } + + const effectiveViewportCenter = () => ({ + x: viewportCenter.x, + y: viewportCenter.y - viewportYOffset, + }) + const worldToScreen = (x: number, y: number) => { const s = scale() const cx = window.innerWidth / 2 const cy = window.innerHeight / 2 + const center = effectiveViewportCenter() return { - x: cx + (x - viewportCenter.x) * s, - y: cy + (y - viewportCenter.y) * s, + x: cx + (x - center.x) * s, + y: cy + (y - center.y) * s, } } @@ -63,12 +76,28 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { const s = scale() const cx = window.innerWidth / 2 const cy = window.innerHeight / 2 + const center = effectiveViewportCenter() return { - x: (sx - cx) / s + viewportCenter.x, - y: viewportCenter.y + (sy - cy) / s, + x: (sx - cx) / s + center.x, + y: center.y + (sy - cy) / s, } } + const clampViewYOffset = (): number => { + // Positive offset pans the view upward (toward the hog line). + const halfHeight = viewportHeight() / 2 + const maxOffset = HOUSE_CENTER.y - halfHeight - HOG_LINE_Y + const minOffset = -(BACK_LINE_Y - (HOUSE_CENTER.y + halfHeight)) + return Math.max(minOffset, Math.min(maxOffset, viewportYOffset)) + } + + const setViewYOffset = (y: number) => { + const halfHeight = viewportHeight() / 2 + const maxOffset = HOUSE_CENTER.y - halfHeight - HOG_LINE_Y + const minOffset = -(BACK_LINE_Y - (HOUSE_CENTER.y + halfHeight)) + viewportYOffset = Math.max(minOffset, Math.min(maxOffset, y)) + } + const drawLine = ( x1: number, y1: number, @@ -129,7 +158,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { stones: StoneState[] broom: { x: number; y: number } | null animating: boolean - activeStonePos: DrawableStone | null + activeStonePos: DrawableStone[] }) => { ctx.clearRect(0, 0, window.innerWidth, window.innerHeight) @@ -149,12 +178,14 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, FOUR_FT_RADIUS, 'rgba(255,255,255,0.12)', 'rgba(255,255,255,0.35)') drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, BUTTON_RADIUS, '#e8e8e8', '#fff') - for (const stone of state.stones) { - drawStone(stone) - } - - if (state.activeStonePos) { - drawStone(state.activeStonePos) + if (state.animating) { + for (const stone of state.activeStonePos) { + drawStone(stone) + } + } else { + for (const stone of state.stones) { + drawStone(stone) + } } if (state.broom) { @@ -170,5 +201,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { setSize() window.addEventListener('resize', setSize) - return { canvas, ctx, setSize, worldToScreen, screenToWorld, draw } + return { canvas, ctx, setSize, worldToScreen, screenToWorld, setViewYOffset, clampViewYOffset, draw } } diff --git a/frontend/src/style.css b/frontend/src/style.css index a402a7f..66ceb15 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -84,30 +84,57 @@ html, body { z-index: 10; } +#hud-top-group { + display: flex; + flex-direction: column; + gap: 4px; +} + #share { - position: absolute; - bottom: 12px; - left: 50%; - transform: translateX(-50%); - text-align: center; + display: flex; + justify-content: center; + width: 100%; +} + +#share-row { + justify-content: center; + margin-bottom: 4px; +} + +#share-row #share { + position: static; + transform: none; } #share button { background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.3); color: white; - padding: 8px 14px; + padding: 6px 12px; border-radius: 12px; - font-size: 13px; + font-size: 12px; pointer-events: auto; } #team { + display: none; +} + +#team-select { font-size: 13px; font-weight: 600; padding: 4px 8px; border-radius: 10px; - background: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.15); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); + pointer-events: auto; + min-width: 80px; +} + +#team-select option { + background: #0b1f3a; + color: white; } #velocity-control { @@ -205,67 +232,6 @@ html, body { text-align: center; } -#team-picker { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.75); - display: flex; - align-items: center; - justify-content: center; - z-index: 20; - pointer-events: auto; -} - -.team-picker-box { - background: #0b1f3a; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 16px; - padding: 24px; - text-align: center; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); -} - -.team-picker-box h2 { - margin: 0 0 16px 0; - font-size: 18px; -} - -.team-picker-buttons { - display: flex; - gap: 16px; - justify-content: center; - flex-wrap: wrap; -} - -.team-btn { - width: 120px; - height: 120px; - border-radius: 16px; - border: 2px solid rgba(255, 255, 255, 0.4); - color: white; - font-weight: 700; - font-size: 16px; - cursor: pointer; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); -} - -.team-btn.team-red { - background: #cc3333; -} -.team-btn.team-red:hover { - background: #e04444; -} - -.team-btn.team-yellow { - background: #ccaa00; -} -.team-btn.team-yellow:hover { - background: #e0be00; -} - #throw-btn { width: 80px; height: 80px; @@ -283,3 +249,53 @@ html, body { background: #557766; color: rgba(255, 255, 255, 0.5); } + +@media (max-width: 480px) { + #hud { + padding: 8px; + } + + .hud-row { + gap: 6px; + } + + #velocity-control { + min-width: 0; + flex: 1 1 auto; + padding: 2px; + } + + .velocity-readout { + min-width: 0; + font-size: 10px; + } + + .curl-btn { + min-width: 44px; + width: 44px; + height: 36px; + font-size: 18px; + border-color: rgba(255, 255, 255, 0.6); + background: rgba(0, 0, 0, 0.5); + } + + #friction-control { + min-width: 0; + flex: 0 1 auto; + padding: 2px; + } + + #friction-slider { + width: 60px; + } + + #friction-value { + font-size: 10px; + } + + #throw-btn { + width: 56px; + height: 56px; + font-size: 12px; + } +} -- 2.47.2 From b06a78227f0852d8be45bd850ce5b8bf486dabc9 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:07 -0700 Subject: [PATCH 14/21] test(e2e): multi-client throws and collision multi-path QA Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- e2e/collision_trajectory_qa.cjs | 72 +++++++++++++++++++++++++++++++++ e2e/e2e_end_score.cjs | 5 +-- e2e/e2e_persistence.cjs | 17 +++----- e2e/e2e_room_full.cjs | 12 ++---- e2e/e2e_score.cjs | 12 +++--- e2e/e2e_test.cjs | 23 +++++------ 6 files changed, 100 insertions(+), 41 deletions(-) create mode 100644 e2e/collision_trajectory_qa.cjs diff --git a/e2e/collision_trajectory_qa.cjs b/e2e/collision_trajectory_qa.cjs new file mode 100644 index 0000000..ffdd0d2 --- /dev/null +++ b/e2e/collision_trajectory_qa.cjs @@ -0,0 +1,72 @@ +const WebSocket = require('ws') +const room = 'COLQA' + Math.floor(Math.random() * 1000) +const url = 'ws://127.0.0.1:3000/ws?room=' + room + +function connect() { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + const messages = [] + ws.on('open', () => resolve({ ws, messages })) + ws.on('message', (data) => messages.push(JSON.parse(data.toString()))) + ws.on('error', reject) + }) +} + +function waitFor(messages, pred, timeout = 10000) { + const start = Date.now() + return new Promise((resolve, reject) => { + const check = () => { + if (pred()) return resolve(undefined) + if (Date.now() - start > timeout) return reject(new Error('timeout')) + setTimeout(check, 50) + } + check() + }) +} + +;(async () => { + const c = await connect() + await waitFor(c.messages, () => { + const last = c.messages[c.messages.length - 1] + return last && last.type === 'game_state' && last.phase === 'playing' + }) + + // First throw: weight 7 so it stays in house. + let state = c.messages[c.messages.length - 1] + c.ws.send(JSON.stringify({ type: 'throw', team: state.turn_team, broom_x: 0.0, broom_y: 38.5, weight: 7, curl: 0, friction: 1.0 })) + await waitFor(c.messages, () => c.messages.filter(m => m.type === 'game_state').length > 1) + state = c.messages[c.messages.length - 1] + console.log('After first throw stones:', state.stones.map(s => ({ id: s.id, x: s.x, y: s.y }))) + if (state.stones.length !== 1) throw new Error('expected first stone in play') + + const trajCountBefore = c.messages.filter(m => m.type === 'trajectory').length + console.log('trajectory count before second throw:', trajCountBefore) + + // Second throw aimed slightly off-center so it hits the first stone. + c.ws.send(JSON.stringify({ type: 'throw', team: state.turn_team, broom_x: 0.25, broom_y: 38.5, weight: 7, curl: 0, friction: 1.0 })) + await waitFor(c.messages, () => c.messages.filter(m => m.type === 'trajectory').length > trajCountBefore) + + const traj = c.messages.filter(m => m.type === 'trajectory').pop() + console.log('Trajectory paths count:', traj.paths.length) + for (const p of traj.paths) { + console.log('stone_id', p.stone_id, 'path length', p.path.length, 'first', p.path[0], 'last', p.path[p.path.length - 1]) + } + + const ids = traj.paths.map(p => p.stone_id).sort((a, b) => a - b) + if (ids.length !== 2 || ids[0] !== 1 || ids[1] !== 2) throw new Error('expected both stone ids in trajectory, got ' + JSON.stringify(ids)) + for (const p of traj.paths) { + if (p.path.length < 5) throw new Error('path too short for stone ' + p.stone_id) + } + + // Verify the first stone actually moved because of collision. + const stone1Path = traj.paths.find(p => p.stone_id === 1).path + const first = stone1Path[0] + const last = stone1Path[stone1Path.length - 1] + const dist = Math.sqrt((last[0]-first[0])**2 + (last[1]-first[1])**2) + console.log('stone1 moved', dist, 'm') + if (dist < 0.05) throw new Error('expected first stone to move after collision') + + console.log('COLLISION TRAJECTORY QA PASSED') + c.ws.close() + process.exit(0) +})().catch(e => { console.error(e); process.exit(1) }) diff --git a/e2e/e2e_end_score.cjs b/e2e/e2e_end_score.cjs index 21f2f50..99ecd62 100644 --- a/e2e/e2e_end_score.cjs +++ b/e2e/e2e_end_score.cjs @@ -9,7 +9,7 @@ function connect(name, room) { ws.on('message', (data) => { const msg = JSON.parse(data.toString()) messages.push(msg) - if (msg.type === 'joined') resolve({ ws, messages, team: msg.team }) + if (msg.type === 'joined') resolve({ ws, messages }) }) ws.on('error', reject) }) @@ -47,9 +47,8 @@ function waitFor(messages, pred, timeout = 30000) { }, 5000) const turn = getTurn() if (!turn) throw new Error('no turn') - const thrower = turn === p1.team ? p1 : p2 const broomX = (Math.random() - 0.5) * 0.6 - thrower.ws.send(JSON.stringify({ type: 'throw', broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) + p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length await waitFor(p1.messages, () => p1.messages.filter(m => m.type === 'game_state').length > prevStateCount, 15000) } diff --git a/e2e/e2e_persistence.cjs b/e2e/e2e_persistence.cjs index 61d31de..e49567a 100644 --- a/e2e/e2e_persistence.cjs +++ b/e2e/e2e_persistence.cjs @@ -12,7 +12,7 @@ function connect(name) { messages.push(msg) console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team} stones=${msg.stones.length}` : '') if (msg.type === 'joined') { - resolve({ ws, messages, team: msg.team }) + resolve({ ws, messages }) } }) ws.on('open', () => console.log(`[${name}] open`)) @@ -38,27 +38,23 @@ function latestState(messages) { ;(async () => { const p1 = await connect('p1') - await waitFor(() => p1.messages.some(m => m.type === 'waiting')) - const p2 = await connect('p2') - await waitFor(() => p2.messages.some(m => m.type === 'game_state')) + await waitFor(() => p1.messages.some(m => m.type === 'game_state')) // Determine the current player and throw two stones in the same end. let state = latestState(p1.messages) console.log('Initial state', state) - // First throw: yellow aims slightly left of center. + // First throw: current turn team. let turn = state.turn_team - let turnPlayer = turn === p1.team ? p1 : p2 - turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) + p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000) state = latestState(p1.messages) console.log('After first throw:', state) - // Second throw: red aims slightly right of center. + // Second throw: other team. turn = state.turn_team - turnPlayer = turn === p1.team ? p1 : p2 - turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 })) + p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 })) await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000) state = latestState(p1.messages) @@ -77,7 +73,6 @@ function latestState(messages) { console.log('PERSISTENCE E2E PASSED') p1.ws.close() - p2.ws.close() process.exit(0) })().catch(err => { console.error(err) diff --git a/e2e/e2e_room_full.cjs b/e2e/e2e_room_full.cjs index 8ff8269..400a587 100644 --- a/e2e/e2e_room_full.cjs +++ b/e2e/e2e_room_full.cjs @@ -28,15 +28,11 @@ function waitFor(messages, pred, timeout = 10000) { const room = 'ROOMFULL' + Math.floor(Math.random() * 1000) const p1 = await connect('p1', room) const p2 = await connect('p2', room) - await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000) const p3 = await connect('p3', room) - await waitFor(p3.messages, () => p3.messages.some(m => m.type === 'error'), 2000) - const p3error = p3.messages.some(m => m.type === 'error' && m.message.includes('Room is full')) - const p1error = p1.messages.some(m => m.type === 'error' && m.message.includes('Room is full')) - const p2error = p2.messages.some(m => m.type === 'error' && m.message.includes('Room is full')) - console.log({ p3error, p1error, p2error }) - if (!p3error) throw new Error('p3 should get room full error') - if (p1error || p2error) throw new Error('existing players should not see room-full error') + await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000) + await waitFor(p2.messages, () => p2.messages.some(m => m.type === 'game_state'), 5000) + await waitFor(p3.messages, () => p3.messages.some(m => m.type === 'game_state'), 5000) + console.log('Multiple observers can watch the same room state') p1.ws.close() p2.ws.close() p3.ws.close() diff --git a/e2e/e2e_score.cjs b/e2e/e2e_score.cjs index de2354f..81b37a9 100644 --- a/e2e/e2e_score.cjs +++ b/e2e/e2e_score.cjs @@ -9,7 +9,7 @@ function connect(name) { ws.on('message', (data) => { const msg = JSON.parse(data.toString()) messages.push(msg) - if (msg.type === 'joined') resolve({ ws, messages, team: msg.team }) + if (msg.type === 'joined') resolve({ ws, messages }) }) ws.on('error', reject) }) @@ -29,26 +29,24 @@ function waitFor(condFn, timeout = 5000) { ;(async () => { const p1 = await connect('p1') - const p2 = await connect('p2') await waitFor(() => p1.messages.some(m => m.type === 'game_state')) let state = p1.messages.find(m => m.type === 'game_state') console.log('start turn', state.turn_team, 'hammer', state.hammer) - const thrower1 = state.turn_team === p1.team ? p1 : p2 - thrower1.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) + const thrower1 = state.turn_team + p1.ws.send(JSON.stringify({ type: 'throw', team: thrower1, broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000) await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) await new Promise(r => setTimeout(r, 500)) state = p1.messages.slice(-1)[0] console.log('After first throw:', state) - const thrower2 = state.turn_team === p1.team ? p1 : p2 - thrower2.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 })) + const thrower2 = state.turn_team + p1.ws.send(JSON.stringify({ type: 'throw', team: thrower2, broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 })) await waitFor(() => p1.messages.filter(m => m.type === 'trajectory').length >= 2, 15000) await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) await new Promise(r => setTimeout(r, 500)) console.log('Final stones', p1.messages.slice(-1)[0].stones) p1.ws.close() - p2.ws.close() process.exit(0) })().catch(e => { console.error(e) diff --git a/e2e/e2e_test.cjs b/e2e/e2e_test.cjs index dce482d..192136f 100644 --- a/e2e/e2e_test.cjs +++ b/e2e/e2e_test.cjs @@ -12,7 +12,7 @@ function connect(name) { messages.push(msg) console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team}` : '') if (msg.type === 'joined') { - resolve({ ws, messages, team: msg.team }) + resolve({ ws, messages }) } }) ws.on('open', () => console.log(`[${name}] open`)) @@ -21,31 +21,30 @@ function connect(name) { }) } -async function waitFor(condFn, timeout = 5000) { +function waitFor(condFn, timeout = 5000) { const start = Date.now() - while (!condFn()) { - if (Date.now() - start > timeout) throw new Error('Timeout waiting') - await new Promise(r => setTimeout(r, 50)) - } + return new Promise((resolve, reject) => { + const check = () => { + if (condFn()) return resolve(undefined) + if (Date.now() - start > timeout) return reject(new Error('Timeout waiting')) + setTimeout(check, 50) + } + check() + }) } ;(async () => { const p1 = await connect('p1') - await waitFor(() => p1.messages.some(m => m.type === 'waiting')) - const p2 = await connect('p2') - await waitFor(() => p2.messages.some(m => m.type === 'joined')) await waitFor(() => p1.messages.some(m => m.type === 'game_state')) const state = p1.messages.find(m => m.type === 'game_state') console.log('Game state', state) const turn = state.turn_team - const turnPlayer = turn === p1.team ? p1 : p2 - turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) + p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000) await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) console.log('Final state after throw:', p1.messages.slice(-1)[0]) p1.ws.close() - p2.ws.close() process.exit(0) })().catch(err => { console.error(err) -- 2.47.2 From dc62c4f55f467995d30641aa7a6b08a5296f3258 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:07 -0700 Subject: [PATCH 15/21] docs: multi-client free team pick and shared watchers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 37b366d..d45fa28 100644 --- a/README.md +++ b/README.md @@ -21,22 +21,24 @@ A 1v1 async multiplayer 2D curling game for mobile browser. npm run dev # opens on 0.0.0.0:5173 by default ``` -4. Open two browser tabs to the same room URL, e.g.: +4. Open one or more browser tabs to the same room URL, e.g.: ``` http://localhost:5173/?room=DEMO1 ``` - The first tab waits; the second starts the match. Use the *Copy share link* button to invite an opponent. + Use the team dropdown in the top-right to switch between Red and Yellow — this enables local pass-and-play on one device. Use the *Copy share link* button to invite an opponent on another device. ### Mobile devices -The frontend binds to `0.0.0.0` via `--host`. Find your machine's LAN IP and open `http://:5173/?room=CODE` on the phone. Both devices must be on the same Wi-Fi and able to reach the backend on port `3000`. +The frontend binds to `0.0.0.0` via `--host`. Find your machine's LAN IP and open `http://:5173/?room=CODE` on the phone. Both devices must be on the same Wi-Fi and able to reach the backend on port `3000`. The default view is zoomed in on the house; drag the sheet vertically to scroll up to the hog line. ### Controls - Drag inside the house to place the broom (aim point). -- Tap a weight `1`–`10`. +- Use the team dropdown to choose which team's stone you are throwing. +- Use the left/right arrow buttons to select curl; the arrow points the direction the stone will curve. - Tap **THROW**. -- The server runs the physics and streams the trajectory; the frontend interpolates the animation. +- The server runs the physics for every stone and streams their paths; the frontend interpolates the animation, so collisions animate smoothly for all stones. +- Drag anywhere outside the house (or when it's not your turn) to scroll up toward the hog line. ### Architecture @@ -54,6 +56,7 @@ cd e2e npm install -g ws # or npm install ws locally in the project node e2e_test.cjs # room lifecycle, throw, trajectory, out-of-play removal node e2e_score.cjs # stones remain in play and alternate turns +node collision_trajectory_qa.cjs # verifies every stone gets a trajectory during a collision ``` ### Limitations / known simplifications -- 2.47.2 From 2e11f005dca270276cb6baa1481419f4223f7811 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:17:15 -0700 Subject: [PATCH 16/21] fix: allow broom aim anywhere, not only house Ultraworked Co-authored-by: Sisyphus --- backend/src/game.rs | 16 ++++++++++------ frontend/src/game.ts | 28 ++++------------------------ 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/backend/src/game.rs b/backend/src/game.rs index 5888960..cf6b2ad 100644 --- a/backend/src/game.rs +++ b/backend/src/game.rs @@ -75,12 +75,6 @@ impl Game { 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 trajectory = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?; @@ -292,4 +286,14 @@ mod tests { let result = game.handle_throw(turn, 0.5, 38.7, 7, 1, 1.0); assert!(result.is_ok()); } + + #[test] + fn accepts_broom_outside_house() { + let mut game = Game::new(); + game.start(); + let turn = game.turn_team; + // (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER + let result = game.handle_throw(turn, 0.0, 30.0, 7, 1, 1.0); + assert!(result.is_ok(), "broom outside house should be allowed: {:?}", result.err()); + } } diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 66ae665..c7f3260 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -1,7 +1,7 @@ import { connect, sendThrow, type NetCallbacks } from './net' import { createRenderer } from './renderer' import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud' -import { HOUSE_CENTER, HOUSE_RADIUS, type Team } from './protocol' +import { type Team } from './protocol' import { GameModel } from './game-model' export function startGame(): void { @@ -123,35 +123,15 @@ export function startGame(): void { return { x: e.clientX, y: e.clientY } } - const constrainBroom = (world: { x: number; y: number }) => { - const dx = world.x - HOUSE_CENTER.x - const dy = world.y - HOUSE_CENTER.y - const dist = Math.sqrt(dx * dx + dy * dy) - if (dist > HOUSE_RADIUS) { - const ratio = HOUSE_RADIUS / dist - return { - x: HOUSE_CENTER.x + dx * ratio, - y: HOUSE_CENTER.y + dy * ratio, - } - } - return world - } - - const isInHouse = (world: { x: number; y: number }) => { - const dx = world.x - HOUSE_CENTER.x - const dy = world.y - HOUSE_CENTER.y - return Math.sqrt(dx * dx + dy * dy) <= HOUSE_RADIUS - } - let lastPanScreenY = 0 const handleStart = (e: Event) => { const pos = getPos(e as TouchEvent) const world = renderer.screenToWorld(pos.x, pos.y) - if (model.isMyTurn && isInHouse(world)) { + if (model.isMyTurn) { model.isDragging = true - model.broom = constrainBroom(world) + model.broom = world } else { model.isPanning = true lastPanScreenY = pos.y @@ -162,7 +142,7 @@ export function startGame(): void { if (model.isDragging && model.isMyTurn) { e.preventDefault() const pos = getPos(e as TouchEvent) - model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) + model.broom = renderer.screenToWorld(pos.x, pos.y) return } -- 2.47.2 From 20c592d8acc92cdd9318aaf8767862ce695633ef Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:17:21 -0700 Subject: [PATCH 17/21] fix(frontend): lock multi-stone parallel trajectory animation Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/game-model.test.ts | 104 ++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 frontend/src/game-model.test.ts diff --git a/frontend/src/game-model.test.ts b/frontend/src/game-model.test.ts new file mode 100644 index 0000000..14ad192 --- /dev/null +++ b/frontend/src/game-model.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { GameModel } from './game-model' +import type { ServerStoneTrajectory, StoneState } from './protocol' + +function stone(partial: Partial & Pick): StoneState { + return { + x: 0, + y: 30, + rotation: 0, + active: true, + ...partial, + } +} + +describe('GameModel multi-path trajectory animation', () => { + it('returns two DrawableStones in parallel mid-trajectory', () => { + const model = new GameModel() + model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })] + model.state.turnTeam = 'red' + + const paths: ServerStoneTrajectory[] = [ + { + stone_id: 1, + path: [ + [0, 10, 0], + [0, 11, 0.5], + [0, 12, 1.0], + ], + }, + { + stone_id: 2, + path: [ + [1, 10, 0], + [1, 11, 0.5], + [1, 12, 1.0], + ], + }, + ] + + model.startTrajectory(paths) + expect(model.state.animating).toBe(true) + + const mid = performance.now() + 500 + const drawn = model.tick(mid) + + expect(drawn).toHaveLength(2) + expect(drawn.map((d) => d.team).sort()).toEqual(['red', 'yellow']) + // Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim shift) + for (const d of drawn) { + expect(d.y).toBeCloseTo(11, 0) + } + }) + + it('clears animating when elapsed reaches maxTotal on a short path', () => { + const model = new GameModel() + model.state.stones = [stone({ id: 1, team: 'red' })] + model.state.turnTeam = 'red' + + model.startTrajectory([ + { + stone_id: 1, + path: [ + [0, 10, 0], + [0, 10.5, 0.2], + [0, 11, 0.4], + ], + }, + ]) + expect(model.state.animating).toBe(true) + + const afterEnd = performance.now() + 500 + const drawn = model.tick(afterEnd) + + expect(drawn).toEqual([]) + expect(model.state.animating).toBe(false) + }) + + it('trims thrown stone path to hog line when id is not in existing stones', () => { + const model = new GameModel() + // Only stone 1 is already on the sheet; stone 2 is the newly thrown rock. + model.state.stones = [stone({ id: 1, team: 'yellow', x: 0.5, y: 35 })] + model.state.turnTeam = 'red' + + const thrownPath: [number, number, number][] = [ + [0, 2, 0], + [0, 20, 1], + [0, 21.5, 2], + [0, 30, 3], + ] + + model.startTrajectory([{ stone_id: 2, path: thrownPath }]) + expect(model.state.animating).toBe(true) + + // Immediately after start: hog-trimmed path begins at y=20 (sample before hog), t=0 + const atStart = performance.now() + const drawn = model.tick(atStart) + + expect(drawn).toHaveLength(1) + expect(drawn[0].team).toBe('red') // turnTeam fallback for unknown id + expect(drawn[0].y).toBeCloseTo(20, 0) + // Must not still be at the hack (y=2) + expect(drawn[0].y).toBeGreaterThan(15) + }) +}) -- 2.47.2 From 2eb576092286f109ec21ee8e9959e8cc143543d7 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:19:02 -0700 Subject: [PATCH 18/21] fix(frontend): camera shows sidelines and house above controls Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/renderer.ts | 41 ++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/frontend/src/renderer.ts b/frontend/src/renderer.ts index fcf64cb..b107dde 100644 --- a/frontend/src/renderer.ts +++ b/frontend/src/renderer.ts @@ -31,11 +31,19 @@ export interface Renderer { export function createRenderer(canvas: HTMLCanvasElement): Renderer { const ctx = canvas.getContext('2d')! - // Default viewport: roughly 14 ft (≈ 4.27 m) wide, centered on the house. - // Height is derived from the screen aspect ratio so the whole sheet is visible. - const viewportWidth = 14.0 / 3.28084 + // Fit full sheet width (5 m) with a little side padding so both sidelines show. + // Scale is primarily width-based, but also capped so house + backline fit above the HUD. + const SIDE_PADDING_M = 0.25 + const viewportWidth = SHEET_WIDTH + SIDE_PADDING_M * 2 + // Min world height: top of 12ft ring through backline + small pad. + const MIN_VIEW_HEIGHT_M = + BACK_LINE_Y - (HOUSE_CENTER.y - TWELVE_FT_RADIUS) + SIDE_PADDING_M + // Throw button (80) + hud padding (12×2) + small gap above controls. + const BOTTOM_UI_PX = 120 const viewportCenter = { x: 0, y: HOUSE_CENTER.y } - let viewportYOffset = 0 + // Prefer backline at the bottom of the usable ice area (just above HUD). + // First setSize()/setViewYOffset clamps this to the valid minOffset. + let viewportYOffset = -(BACK_LINE_Y - HOUSE_CENTER.y) const setSize = () => { const dpr = window.devicePixelRatio || 1 @@ -46,14 +54,29 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { canvas.style.width = `${width}px` canvas.style.height = `${height}px` ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + // Re-clamp so default framing keeps the backline just above the HUD. + setViewYOffset(viewportYOffset) + } + + /** Screen height available for ice (above bottom controls). */ + const usableHeightPx = (): number => { + return Math.max(1, window.innerHeight - BOTTOM_UI_PX) } const scale = (): number => { - return window.innerWidth / viewportWidth + const byWidth = window.innerWidth / viewportWidth + const byHeight = usableHeightPx() / MIN_VIEW_HEIGHT_M + // Width fit for sidelines; height cap so house/backline stay on-screen on wide displays. + return Math.min(byWidth, byHeight) } const viewportHeight = (): number => { - return window.innerHeight / scale() + return usableHeightPx() / scale() + } + + /** Vertical center of the usable ice area (not the full window). */ + const viewCenterY = (): number => { + return usableHeightPx() / 2 } const effectiveViewportCenter = () => ({ @@ -64,7 +87,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { const worldToScreen = (x: number, y: number) => { const s = scale() const cx = window.innerWidth / 2 - const cy = window.innerHeight / 2 + const cy = viewCenterY() const center = effectiveViewportCenter() return { x: cx + (x - center.x) * s, @@ -75,7 +98,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { const screenToWorld = (sx: number, sy: number) => { const s = scale() const cx = window.innerWidth / 2 - const cy = window.innerHeight / 2 + const cy = viewCenterY() const center = effectiveViewportCenter() return { x: (sx - cx) / s + center.x, @@ -85,6 +108,8 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { const clampViewYOffset = (): number => { // Positive offset pans the view upward (toward the hog line). + // When the usable viewport is tall enough, minOffset pins BACK_LINE_Y to the + // bottom of the usable area (just above the HUD), not under the controls. const halfHeight = viewportHeight() / 2 const maxOffset = HOUSE_CENTER.y - halfHeight - HOG_LINE_Y const minOffset = -(BACK_LINE_Y - (HOUSE_CENTER.y + halfHeight)) -- 2.47.2 From 1e720c22cb4ac2f82ab5bea3e2daa9679c59d4ba Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:19:45 -0700 Subject: [PATCH 19/21] refactor: express foot-derived constants via FEET_TO_METERS House radius (12ft diameter -> 6ft radius), button (6in -> 0.5ft), four-ft, eight-ft, twelve-ft radii now computed at compile time from FEET_TO_METERS = 0.3048. Ultraworked + Co-authored-by --- backend/src/protocol.rs | 3 ++- frontend/src/protocol.ts | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/src/protocol.rs b/backend/src/protocol.rs index decf70b..79e3964 100644 --- a/backend/src/protocol.rs +++ b/backend/src/protocol.rs @@ -4,6 +4,7 @@ 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 FEET_TO_METERS: f32 = 0.3048; pub const STONES_PER_TEAM: u8 = 8; pub const ENDS: u8 = 10; @@ -12,7 +13,7 @@ pub const ENDS: u8 = 10; 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 HOUSE_RADIUS: f32 = 6.0 * FEET_TO_METERS; // 12 ft diameter → 6 ft radius pub const HOG_LINE_Y: f32 = 21.0; pub const BACK_LINE_Y: f32 = 42.0; pub const HACK_Y: f32 = 2.0; diff --git a/frontend/src/protocol.ts b/frontend/src/protocol.ts index 7e74f8c..2c9ba22 100644 --- a/frontend/src/protocol.ts +++ b/frontend/src/protocol.ts @@ -4,12 +4,13 @@ export const STONES_PER_TEAM = 8 export const ENDS = 10 export const SHEET_WIDTH = 5.0 export const SHEET_LENGTH = 45.0 +export const FEET_TO_METERS = 0.3048 export const HOUSE_CENTER = { x: 0, y: 38.5 } -export const HOUSE_RADIUS = 1.83 -export const BUTTON_RADIUS = 0.1524 -export const FOUR_FT_RADIUS = 0.6096 -export const EIGHT_FT_RADIUS = 1.2192 -export const TWELVE_FT_RADIUS = 1.8288 +export const HOUSE_RADIUS = 6 * FEET_TO_METERS +export const BUTTON_RADIUS = 0.5 * FEET_TO_METERS +export const FOUR_FT_RADIUS = 2 * FEET_TO_METERS +export const EIGHT_FT_RADIUS = 4 * FEET_TO_METERS +export const TWELVE_FT_RADIUS = 6 * FEET_TO_METERS export const HOG_LINE_Y = 21.0 export const BACK_LINE_Y = 42.0 export const HACK_Y = 2.0 -- 2.47.2 From 1239394578a870ffabf3dd9efb092b2f85dc45e0 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:21:55 -0700 Subject: [PATCH 20/21] test(e2e): multi-client observers replace room-full check --- e2e/e2e_multi_client.cjs | 75 ++++++++++++++++++++++++++++++++++++++++ e2e/e2e_room_full.cjs | 43 ----------------------- 2 files changed, 75 insertions(+), 43 deletions(-) create mode 100644 e2e/e2e_multi_client.cjs delete mode 100644 e2e/e2e_room_full.cjs diff --git a/e2e/e2e_multi_client.cjs b/e2e/e2e_multi_client.cjs new file mode 100644 index 0000000..839a915 --- /dev/null +++ b/e2e/e2e_multi_client.cjs @@ -0,0 +1,75 @@ +const WebSocket = require('ws') + +const room = 'MULTI' + Math.floor(Math.random() * 1000) +const base = 'ws://127.0.0.1:3000/ws?room=' + room + +function connect(name) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(base) + const messages = [] + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) + messages.push(msg) + console.log(`[${name}]`, msg.type) + if (msg.type === 'joined') { + resolve({ ws, messages }) + } + }) + ws.on('open', () => console.log(`[${name}] open`)) + ws.on('error', (e) => { console.error(`[${name}] error`, e.message); reject(e) }) + ws.on('close', (code) => console.log(`[${name}] close`, code)) + }) +} + +function waitFor(client, pred, timeout = 10000) { + const start = Date.now() + return new Promise((resolve, reject) => { + const check = () => { + if (pred(client.messages)) return resolve(undefined) + if (Date.now() - start > timeout) return reject(new Error('timeout')) + setTimeout(check, 50) + } + check() + }) +} + +;(async () => { + // 3 clients join the same room + const p1 = await connect('p1') + const p2 = await connect('p2') + const p3 = await connect('p3') + + // All 3 receive game_state after join + await waitFor(p1, msgs => msgs.some(m => m.type === 'game_state'), 5000) + await waitFor(p2, msgs => msgs.some(m => m.type === 'game_state'), 5000) + await waitFor(p3, msgs => msgs.some(m => m.type === 'game_state'), 5000) + console.log('All 3 clients received game_state') + + // p1 throws for the current turn_team + const state = p1.messages.find(m => m.type === 'game_state') + const turn = state.turn_team + console.log(`p1 throwing for team ${turn}`) + p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) + + // All 3 clients eventually see trajectory or updated game_state + await waitFor(p1, msgs => msgs.some(m => m.type === 'trajectory'), 15000) + await waitFor(p2, msgs => msgs.some(m => m.type === 'trajectory'), 15000) + await waitFor(p3, msgs => msgs.some(m => m.type === 'trajectory'), 15000) + console.log('All 3 clients received trajectory') + + // All 3 see an updated game_state after the throw + const lastIdx = p1.messages.length - 1 + await waitFor(p1, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000) + await waitFor(p2, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000) + await waitFor(p3, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000) + console.log('All 3 clients received updated game_state after throw') + + console.log('Multi-client observer test passed') + p1.ws.close() + p2.ws.close() + p3.ws.close() + process.exit(0) +})().catch(err => { + console.error(err) + process.exit(1) +}) \ No newline at end of file diff --git a/e2e/e2e_room_full.cjs b/e2e/e2e_room_full.cjs deleted file mode 100644 index 400a587..0000000 --- a/e2e/e2e_room_full.cjs +++ /dev/null @@ -1,43 +0,0 @@ -const WebSocket = require('ws') - -const base = (room) => `ws://127.0.0.1:3000/ws?room=${room}` - -function connect(name, room) { - return new Promise((resolve, reject) => { - const ws = new WebSocket(base(room)) - const messages = [] - ws.on('open', () => resolve({ ws, messages })) - ws.on('message', (data) => messages.push(JSON.parse(data.toString()))) - ws.on('error', reject) - }) -} - -function waitFor(messages, pred, timeout = 10000) { - const start = Date.now() - return new Promise((resolve, reject) => { - const check = () => { - if (pred()) return resolve(undefined) - if (Date.now() - start > timeout) return reject(new Error('timeout')) - setTimeout(check, 50) - } - check() - }) -} - -;(async () => { - const room = 'ROOMFULL' + Math.floor(Math.random() * 1000) - const p1 = await connect('p1', room) - const p2 = await connect('p2', room) - const p3 = await connect('p3', room) - await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000) - await waitFor(p2.messages, () => p2.messages.some(m => m.type === 'game_state'), 5000) - await waitFor(p3.messages, () => p3.messages.some(m => m.type === 'game_state'), 5000) - console.log('Multiple observers can watch the same room state') - p1.ws.close() - p2.ws.close() - p3.ws.close() - process.exit(0) -})().catch((e) => { - console.error(e) - process.exit(1) -}) -- 2.47.2 From 473e18a7519c675a12ff05bd1f7a6194786a4b09 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:28:58 -0700 Subject: [PATCH 21/21] docs: multi-client free team, free broom, multi-path e2e list Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d45fa28..0e13e05 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## curltastic -A 1v1 async multiplayer 2D curling game for mobile browser. +A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick Red or Yellow freely, and throw when it is that team's turn (solo pass-and-play or remote opponents). - **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz). - **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI. @@ -33,12 +33,12 @@ The frontend binds to `0.0.0.0` via `--host`. Find your machine's LAN IP and ope ### Controls -- Drag inside the house to place the broom (aim point). -- Use the team dropdown to choose which team's stone you are throwing. -- Use the left/right arrow buttons to select curl; the arrow points the direction the stone will curve. -- Tap **THROW**. -- The server runs the physics for every stone and streams their paths; the frontend interpolates the animation, so collisions animate smoothly for all stones. -- Drag anywhere outside the house (or when it's not your turn) to scroll up toward the hog line. +- When it is your team's turn, drag on the sheet to place the broom (aim point) — aim is not limited to the house. +- When it is not your turn, drag to pan the ice (default framing shows the house with sidelines; pan up toward the hog line). +- Use the team dropdown to choose which team's stone you are throwing; switch any time (including mid-end for solo play). +- Use the left/right curl buttons and the weight/friction controls; friction is a local scalar. +- Tap **THROW**. Anyone identifying as the turn team may throw. +- The server runs physics for every stone and streams multi-stone paths; the client animates them on one clock so collisions move together. ### Architecture @@ -54,9 +54,12 @@ With the backend and frontend dev server running: ```bash cd e2e npm install -g ws # or npm install ws locally in the project -node e2e_test.cjs # room lifecycle, throw, trajectory, out-of-play removal -node e2e_score.cjs # stones remain in play and alternate turns -node collision_trajectory_qa.cjs # verifies every stone gets a trajectory during a collision +node e2e_test.cjs # room lifecycle, throw, trajectory +node e2e_score.cjs # alternate turns / stones in play +node e2e_persistence.cjs # multi-throw stone persistence +node e2e_multi_client.cjs # 3 clients share state (no room-full) +node e2e_end_score.cjs # full end → end_scored + next end +node collision_trajectory_qa.cjs # multi-stone trajectory on collision ``` ### Limitations / known simplifications -- 2.47.2