Compare commits

...

4 Commits

Author SHA1 Message Date
5877bd3d8a Merge pull request 'docs: move product spec to .pm/board.yaml' (#3) from swengineer/curltastic:pm-board-spec into main
Reviewed-on: #3
2026-09-07 17:59:46 -07:00
440d383067 Merge branch 'main' into pm-board-spec 2026-09-07 17:58:50 -07:00
Jason Dekarske
78b8ebeb7d docs: move product spec to .pm/board.yaml
Replace REQUIREMENTS.md as source of truth with the pm-board yaml.
Drop README friction-scalar lies. Include pending HUD frontend work.
2026-09-07 23:06:31 +00:00
Jason Dekarske
3bdd67f815 docs: record product requirements as complete sentences
Capture the agreed 34 requirements in REQUIREMENTS.md. Implementation
details are omitted.
2026-09-01 05:55:27 +00:00
9 changed files with 262 additions and 133 deletions

122
.pm/board.yaml Normal file
View File

@ -0,0 +1,122 @@
requirements:
- id: R1
title: Mobile 2D overhead curling
body: Players play curling in a mobile browser from a 2D overhead view.
- id: R2
title: Portrait sheet
body: The sheet is shown in portrait.
- id: R3
title: Two teams, unlimited clients
body: A room has two teams and an unlimited number of clients.
- id: R4
title: Team pick per device
body: Each client picks Team 1 or Team 2 on that device.
- id: R5
title: Anyone on turn may throw
body: Anyone on the team whose turn it is may throw.
- id: R6
title: Shareable room link
body: Players join a room from a shareable link.
- id: R7
title: Create room from empty link
body: If the link has no room, the client creates one and writes that room into the URL.
- id: R8
title: Ten ends, eight stones
body: A match has 10 ends. Each team has 8 stones per end.
- id: R9
title: Extra ends until a lead
body: Extra ends are played while the score is tied after 10 ends. The match ends when one team leads after an end.
- id: R10
title: Random hammer
body: Hammer is random at the start. The team without hammer throws first in the end.
- id: R11
title: Hammer after score or blank
body: The team that scores gives hammer away. A blank end keeps hammer.
- id: R12
title: Alternate throws
body: After a throw, that team has one fewer stone and the other team throws.
- id: R13
title: Score when stones are gone
body: When all throws have completed, the end is scored.
- id: R14
title: House only scores
body: Only stones touching the house can score.
- id: R15
title: Count to opponent nearest
body: The team closest to the tee scores one point for each of its stones nearer than the opponent's nearest stone.
- id: R16
title: Blank end is 0-0
body: If no stone can score, the end is 0-0.
- id: R17
title: Shared live state
body: Every client sees the current end, both scores, hammer, whose turn it is, stones remaining, stone positions, trajectories from all shots, and whether the match is over.
- id: R18
title: Winner on match end
body: When the match ends, both scores are shown with the winning team.
- id: R19
title: Hack to broom
body: A thrown stone starts at the hack and travels toward the broom. The player chooses initial speed and curl.
- id: R20
title: Clockwise is thrower's right
body: Clockwise curl moves the stone to the thrower's right.
- id: R21
title: Stone bounce, no line bounce
body: Stones bounce off other stones. Stones do not bounce back from a sideline or the backline.
- id: R22
title: Out of play rules
body: A stone is out if it touches a sideline. A stone must fully cross the backline to be out. A stone is out if it never fully crosses the hog line.
- id: R23
title: Sheet markings
body: The ice is a curling sheet with a house, hog line, sidelines, and a backline on the 12-foot ring.
- id: R24
title: Default house framing
body: The default view frames the house, sidelines, and backline above the controls.
- id: R25
title: Pan to hog
body: The player can pan up and down the sheet to the hog line.
- id: R26
title: Broom anywhere then throw
body: On turn, the player drags to place the broom anywhere on the sheet, then throws.
- id: R27
title: Discrete throw weights
body: The player sets throw weight on a discrete scale of draw weights 1 through 10, then named takeouts hack, board, control, normal, and peel.
- id: R28
title: Curl direction
body: The player sets curl clockwise or counterclockwise. Clockwise is the default.
- id: R29
title: Throw only on turn while still
body: Throw controls work only on that team's turn and only while stones are not moving.
- id: R30
title: Stones, hammer, scoreboard HUD
body: The HUD shows eight remaining-stone markers per team, a hammer marker, and a scoreboard with each end and a total.
- id: R31
title: End overlay
body: After the stones stop and the end is scored, an end-result overlay appears. The player dismisses it.
- id: R32
title: Shared motion, hide out stones
body: All clients see the same stone motion. A stone that leaves play is hidden when it leaves the sheet.
- id: R33
title: Copyable errors
body: Errors and the final result appear on the page as copyable text, not as a blocking alert.
- id: R34
title: No pinch-zoom, remember team
body: Pinch-zoom is off. The client remembers the last team choice.
choices:
- id: C1
title: Stack
body: Backend is Rust, Axum, WebSocket, Rapier2D, server-authoritative at 120 Hz. Frontend is TypeScript, Vite, Canvas2D, portrait-first touch.
- id: C2
title: Team colours
body: Team 1 is red. Team 2 is yellow.
- id: C3
title: Wire protocol
body: WebSocket JSON with snake_case tags. game_state carries totals, hammer, turn_team, per-end scoreboard, stones_remaining, and stones. Trajectories sample at 40 Hz from the 120 Hz sim. No room-full limit. No end_scored message.
- id: C4
title: Physics how
body: Ice friction is a server µ(v) table shared by linear and angular damping. Curl uses initial |ω| = 5 rot / 14 s with clockwise to the thrower's right. Stone-stone restitution is 0.9. Sidelines and backline are not colliders; out-of-play is applied after sim. Stone ids are { team, n } with n 1 through 8. Lengths use FEET_TO_METERS = 0.3048.
- id: C5
title: Weight mapping
body: Discrete UI weights map to calibrated open-ice stop speeds in SPEED_TABLE. The server takes velocity and curl, not a local friction scalar.
- id: C6
title: Out of scope
body: No accounts, persistence, anti-cheat, replay log, turn timer, or sweeping.

1
.pm/outbox.md Normal file
View File

@ -0,0 +1 @@
dirty: board.yaml

View File

@ -2,6 +2,8 @@
A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick **Team 1** or **Team 2** freely, and throw when it is that team's turn (solo pass-and-play or remote opponents). Default palette: Team 1 red, Team 2 yellow. A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick **Team 1** or **Team 2** freely, and throw when it is that team's turn (solo pass-and-play or remote opponents). Default palette: Team 1 red, Team 2 yellow.
Product must-haves and decided hows live in `.pm/board.yaml`.
- **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz). - **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI. - **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
@ -32,15 +34,15 @@ A multiplayer 2D curling game for mobile browser. Any number of clients can join
- On your team's turn, drag on solid ice to place the broom (aim is **not** limited to the house). - On your team's turn, drag on solid ice to place the broom (aim is **not** limited to the house).
- Otherwise, drag to pan (default framing shows house + sidelines; pan up toward the hog line). - Otherwise, drag to pan (default framing shows house + sidelines; pan up toward the hog line).
- Team dropdown: free switch mid-game (solo: throw for Team 1, switch, throw for Team 2). - Team dropdown: free switch mid-game (solo: throw for Team 1, switch, throw for Team 2).
- Velocity slider (release speed, m/s), curl buttons, friction scalar **0.51.5** (local; multiplies ice µ(v) table). - Weight slider: draws 110 then takeouts hack, board, control, normal, peel. Curl buttons.
- Tap **THROW**. Anyone joined as the current turn team may throw. - Tap **THROW**. Anyone joined as the current turn team may throw.
- Parallel multi-stone trajectories; rotation θ comes from physics. - Parallel multi-stone trajectories; rotation θ comes from physics.
- Skeuomorphic 2×8 stones-left HUD; end-of-end modal with scoreboard (auto ~5s + manual dismiss). - Skeuomorphic 2×8 stones-left HUD; end-of-end overlay with scoreboard (player dismisses).
### Physics (high level) ### Physics (high level)
- Pure initial **velocity** (m/s), not discrete weight. - Discrete weight labels map to calibrated release speeds (m/s) on the server.
- Ice friction µ(v) table (interpolated) × local scalar; linear and angular damping share the table. - Ice friction µ(v) table (interpolated) on the server; linear and angular damping share the table.
- Curl: initial |ω| = 5 rot / 14 s; lateral continuous model (clockwise → right). - Curl: initial |ω| = 5 rot / 14 s; lateral continuous model (clockwise → right).
- Stonestone contacts are **nearly elastic** (`STONE_RESTITUTION = 0.9`) so takeouts launch both rocks along the impact line instead of plastic-sticking. - Stonestone contacts are **nearly elastic** (`STONE_RESTITUTION = 0.9`) so takeouts launch both rocks along the impact line instead of plastic-sticking.
- Back line and sidelines are **not** colliders — touch → out of play after sim. - Back line and sidelines are **not** colliders — touch → out of play after sim.
@ -72,13 +74,8 @@ node load_test.cjs # optional concurrent rooms
Unit tests: `cd backend && cargo test`, `cd frontend && npm test`. Unit tests: `cd backend && cargo test`, `cd frontend && npm test`.
### Branches (this work)
- **`jasonlooked`** — PR-A refactor: multi-client, multi-path animation, free broom, camera, FEET_TO_METERS.
- **`pr2-features`** — PR-B features: physics rewrite, team1/2 wire, scoreboard, HUD, modal (branched from PR-A tip).
### Limitations ### Limitations
- No accounts, persistence, anti-cheat, replay log, or turn timer. - No accounts, persistence, anti-cheat, replay log, or turn timer.
- Sweeping not implemented; friction/curl not shared-room state. - Sweeping not implemented.
- Stones past back/sideline/hog rules are removed from play after simulation. - Stones past back/sideline/hog rules are removed from play after simulation.

1
REQUIREMENTS.md Normal file
View File

@ -0,0 +1 @@
Product requirements live in `.pm/board.yaml`.

View File

@ -139,7 +139,9 @@ export class GameModel {
if (thrownId !== null) { if (thrownId !== null) {
const thrownPath = const thrownPath =
stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? [] stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
this.pathClockOffset = hogTrimStartIndex(thrownPath) const hogIdx = hogTrimStartIndex(thrownPath)
// Leave at least one segment after offset so hog start still plays.
this.pathClockOffset = Math.min(hogIdx, Math.max(0, thrownPath.length - 2))
} }
const pathMap = new Map< const pathMap = new Map<
@ -155,9 +157,7 @@ export class GameModel {
} }
this.activePaths = pathMap this.activePaths = pathMap
this.state.animating = Array.from(pathMap.values()).some( this.state.animating = Array.from(pathMap.values()).some((p) => p.path.length > 1)
(p) => p.path.length > this.pathClockOffset + 1,
)
this.animationStartTime = performance.now() this.animationStartTime = performance.now()
// Fresh throw: wait for the matching game_state. // Fresh throw: wait for the matching game_state.
this.pendingStones = [] this.pendingStones = []

View File

@ -43,6 +43,8 @@ export function startGame(): void {
scoreboard: typeof model.state.scoreboard scoreboard: typeof model.state.scoreboard
} }
let deferredEndModal: EndModalPayload | null = null let deferredEndModal: EndModalPayload | null = null
/** True after trajectories for the current throw until animation ends. */
let throwAnimPending = false
const stored = localStorage.getItem('curltastic-team') const stored = localStorage.getItem('curltastic-team')
const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1' const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1'
@ -78,7 +80,7 @@ export function startGame(): void {
} }
const flushEndModal = () => { const flushEndModal = () => {
if (!deferredEndModal || model.state.animating) return if (!deferredEndModal || model.state.animating || throwAnimPending) return
hud.showEndModal(deferredEndModal) hud.showEndModal(deferredEndModal)
deferredEndModal = null deferredEndModal = null
} }
@ -92,6 +94,7 @@ export function startGame(): void {
const wasAnimating = model.state.animating const wasAnimating = model.state.animating
const activeStonePos = model.tick(performance.now()) const activeStonePos = model.tick(performance.now())
if (wasAnimating && !model.state.animating) { if (wasAnimating && !model.state.animating) {
throwAnimPending = false
flushEndModal() flushEndModal()
updateControls() updateControls()
} }
@ -121,14 +124,17 @@ export function startGame(): void {
onGameState: (msg) => { onGameState: (msg) => {
model.updateGameState(msg) model.updateGameState(msg)
queueEndModalIfNeeded() queueEndModalIfNeeded()
// Never flush here: game_state can arrive before/during throw anim. // Only flush if this throw is not mid-animation (or never animated).
// Modal is flushed when the trajectory animation ends (or traj is a no-op). flushEndModal()
updateControls() updateControls()
}, },
onTrajectories: (stones) => { onTrajectories: (stones) => {
model.startTrajectory(stones) model.startTrajectory(stones)
// Short / empty paths finish immediately — show end modal if already queued. // Hold end modal until playback finishes (even if sim paths were short).
flushEndModal() throwAnimPending = model.state.animating
if (!throwAnimPending) {
flushEndModal()
}
updateControls() updateControls()
}, },
onGameOver: (scores, winner) => { onGameOver: (scores, winner) => {
@ -202,6 +208,8 @@ export function startGame(): void {
throwBtn.addEventListener('click', () => { throwBtn.addEventListener('click', () => {
if (!model.isMyTurn) return if (!model.isMyTurn) return
// Hold end-of-end modal until trajectories + playback complete.
throwAnimPending = true
sendThrow( sendThrow(
hud.teamSelect.value as Team, hud.teamSelect.value as Team,
model.broom.x, model.broom.x,

View File

@ -71,6 +71,25 @@ const TEAM_LABELS: Record<Team, string> = {
team2: 'Team 2', team2: 'Team 2',
} }
/** "Team 1 stole 1!" / "Team 2 scored 4!" from the completed end row. */
function endResultHeadline(payload: {
team1: number
team2: number
scoreboard: readonly EndScore[]
}): string {
const entry = payload.scoreboard[payload.scoreboard.length - 1]
const hammer = entry?.hammer
const t1 = payload.team1
const t2 = payload.team2
if (t1 === 0 && t2 === 0) return 'Blank end!'
if (t1 > 0) {
const stole = hammer === 'team2'
return stole ? `Team 1 stole ${t1}!` : `Team 1 scored ${t1}!`
}
const stole = hammer === 'team1'
return stole ? `Team 2 stole ${t2}!` : `Team 2 scored ${t2}!`
}
function buildStoneChipsHtml(team: Team): string { function buildStoneChipsHtml(team: Team): string {
const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => { const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => {
return `<span class="stone-chip stone-chip--${team}" data-index="${i}" aria-hidden="true"></span>` return `<span class="stone-chip stone-chip--${team}" data-index="${i}" aria-hidden="true"></span>`
@ -79,31 +98,22 @@ function buildStoneChipsHtml(team: Team): string {
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>` return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>`
} }
function scoreboardTotals(scoreboard: readonly EndScore[]): [number, number] {
let t1 = 0
let t2 = 0
for (const e of scoreboard) {
t1 += e.team1
t2 += e.team2
}
return [t1, t2]
}
export function createHud(): Hud { export function createHud(): Hud {
const root = document.createElement('div') const root = document.createElement('div')
root.id = 'hud' root.id = 'hud'
root.innerHTML = ` root.innerHTML = `
<div id="hud-top-group"> <div id="hud-top-group">
<div class="hud-row" id="share-row"> <div id="hud-status-row" aria-live="polite">
<div id="stones-hud">
${buildStoneChipsHtml('team1')}
${buildStoneChipsHtml('team2')}
</div>
<div id="scoreboard-baseball" class="scoreboard-baseball-wrap"></div>
</div>
<div class="hud-row" id="hud-meta-row">
<div id="share"><button type="button">Copy share link</button></div> <div id="share"><button type="button">Copy share link</button></div>
</div> <div id="turn-info">Waiting</div>
<div id="stones-hud" aria-live="polite">
${buildStoneChipsHtml('team1')}
${buildStoneChipsHtml('team2')}
</div>
<div id="scoreboard-baseball" class="scoreboard-baseball-wrap" aria-live="polite"></div>
<div class="hud-row">
<div id="end-info">End 1 · Waiting</div>
<select id="team-select" aria-label="Team"> <select id="team-select" aria-label="Team">
<option value="team1">Team 1</option> <option value="team1">Team 1</option>
<option value="team2">Team 2</option> <option value="team2">Team 2</option>
@ -120,7 +130,7 @@ export function createHud(): Hud {
<div id="waiting">Waiting</div> <div id="waiting">Waiting</div>
` `
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')! const turnInfoEl = root.querySelector<HTMLDivElement>('#turn-info')!
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')! const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')! const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')! const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
@ -207,9 +217,8 @@ export function createHud(): Hud {
state.phase === 'playing' state.phase === 'playing'
? `${TEAM_LABELS[state.turnTeam]}'s turn` ? `${TEAM_LABELS[state.turnTeam]}'s turn`
: state.phase.replace(/_/g, ' ') : state.phase.replace(/_/g, ' ')
endInfoEl.textContent = `End ${state.end} · ${phaseText}` turnInfoEl.textContent = phaseText
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating) waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
// Hammer class first so stones-remaining aria can mention it.
updateHammerBadge(state.hammer) updateHammerBadge(state.hammer)
updateStonesRemaining(state.stonesRemaining) updateStonesRemaining(state.stonesRemaining)
updateScoreboard(state.scoreboard, state.scores, { updateScoreboard(state.scoreboard, state.scores, {
@ -224,26 +233,11 @@ export function createHud(): Hud {
modal.setAttribute('role', 'dialog') modal.setAttribute('role', 'dialog')
modal.setAttribute('aria-modal', 'true') modal.setAttribute('aria-modal', 'true')
modal.setAttribute('aria-labelledby', 'end-modal-title') modal.setAttribute('aria-labelledby', 'end-modal-title')
const boardHtml = renderBaseballScoreboard( const headline = endResultHeadline(payload)
payload.scoreboard,
scoreboardTotals(payload.scoreboard),
{
ends: ENDS,
hammer: payload.nextHammer,
currentEnd: payload.end,
},
)
modal.innerHTML = ` modal.innerHTML = `
<div class="end-modal-backdrop" data-dismiss="1"></div> <div class="end-modal-backdrop" data-dismiss="1"></div>
<div class="end-modal-card"> <div class="end-modal-card end-modal-card--compact">
<h2 id="end-modal-title">End ${payload.end} complete</h2> <h2 id="end-modal-title">${headline}</h2>
<p class="end-modal-points">
<span class="end-modal-team end-modal-team--team1">Team 1 <strong>${payload.team1}</strong></span>
<span class="end-modal-vs">vs</span>
<span class="end-modal-team end-modal-team--team2">Team 2 <strong>${payload.team2}</strong></span>
</p>
<p class="end-modal-hammer">Next hammer: <strong>${TEAM_LABELS[payload.nextHammer]}</strong></p>
<div class="end-modal-board">${boardHtml}</div>
<button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button> <button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button>
</div> </div>
` `
@ -285,57 +279,24 @@ export function createVelocitySelector(
const wrap = document.createElement('div') const wrap = document.createElement('div')
wrap.className = 'velocity-control-inner' wrap.className = 'velocity-control-inner'
const title = document.createElement('label')
title.textContent = 'Weight'
wrap.appendChild(title)
const slider = document.createElement('input') const slider = document.createElement('input')
slider.type = 'range' slider.type = 'range'
slider.min = '0' slider.min = '0'
slider.max = String(14) // 1..10 + hack/board/control/normal/peel slider.max = '14'
slider.step = '1' slider.step = '1'
slider.value = String(state.index) slider.value = String(state.index)
slider.className = 'velocity-slider' slider.className = 'velocity-slider'
slider.setAttribute('list', 'weight-marks')
slider.ariaLabel = 'Throw weight' slider.ariaLabel = 'Throw weight'
const marks = document.createElement('datalist')
marks.id = 'weight-marks'
const tickLabels = [1, 5, 7, 10, 'hack', 'board', 'control', 'normal', 'peel'] as const
for (const tick of tickLabels) {
const opt = document.createElement('option')
opt.value = String(indexOfLabel(tick))
opt.label = String(tick)
marks.appendChild(opt)
}
const ticks = document.createElement('div')
ticks.className = 'velocity-ticks'
ticks.setAttribute('aria-hidden', 'true')
const named = ['hack', 'board', 'control', 'normal', 'peel'] as const
ticks.innerHTML = named
.map((name) => `<span class="velocity-tick" data-label="${name}">${name}</span>`)
.join('')
const readout = document.createElement('div') const readout = document.createElement('div')
readout.className = 'velocity-readout' readout.className = 'velocity-readout'
wrap.appendChild(slider) wrap.appendChild(slider)
wrap.appendChild(marks)
wrap.appendChild(ticks)
wrap.appendChild(readout) wrap.appendChild(readout)
const update = () => { const update = () => {
state.index = clampSpeedIndex(Number(slider.value)) state.index = clampSpeedIndex(Number(slider.value))
const label = labelAtIndex(state.index) const label = labelAtIndex(state.index)
const v = velocityForLabel(label) readout.textContent = formatSpeedLabel(label)
const name = formatSpeedLabel(label)
const isTakeout = typeof label === 'string'
readout.textContent = isTakeout
? `${name} · ${v.toFixed(3)} m/s`
: `${name} · ${v.toFixed(3)} m/s`
ticks.querySelectorAll<HTMLSpanElement>('.velocity-tick').forEach((el) => {
el.classList.toggle('velocity-tick--active', el.dataset.label === String(label))
})
onSelect() onSelect()
} }
slider.addEventListener('input', update) slider.addEventListener('input', update)

View File

@ -178,9 +178,6 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
ctx.arc(0, 0, r, 0, Math.PI * 2) ctx.arc(0, 0, r, 0, Math.PI * 2)
ctx.fillStyle = bodyGrad ctx.fillStyle = bodyGrad
ctx.fill() ctx.fill()
ctx.strokeStyle = 'rgba(255,255,255,0.9)'
ctx.lineWidth = Math.max(1.5, scale() * 0.02)
ctx.stroke()
// Asymmetric handle: bright bar + dark toe so spin reads clearly. // Asymmetric handle: bright bar + dark toe so spin reads clearly.
ctx.fillStyle = 'rgba(255,255,255,0.92)' ctx.fillStyle = 'rgba(255,255,255,0.92)'

View File

@ -87,29 +87,57 @@ html, body {
#hud-top-group { #hud-top-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 6px;
} }
/* —— Stones remaining (2×8 skeuomorphic chips) —— */ #hud-status-row {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: space-between;
gap: 8px;
width: 100%;
}
#hud-meta-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
#turn-info {
flex: 1;
text-align: center;
font-size: 13px;
font-weight: 600;
opacity: 0.92;
}
/* —— Stones remaining (2×8 chips) —— */
#stones-hud { #stones-hud {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 6px; justify-content: center;
padding: 6px 10px; gap: 4px;
margin: 0 auto 2px; flex: 1 1 50%;
min-width: 0;
padding: 4px 6px;
margin: 0;
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.18); border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 14px; border-radius: 10px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 4px 12px rgba(0, 0, 0, 0.25); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 8px rgba(0, 0, 0, 0.25);
pointer-events: none; pointer-events: none;
} }
.stones-row { .stones-row {
display: grid; display: grid;
/* Fixed hammer column so both team chips align vertically under each other. */ grid-template-columns: 12px repeat(8, 12px);
grid-template-columns: 18px repeat(8, 18px); column-gap: 3px;
column-gap: 5px;
align-items: center; align-items: center;
justify-items: center; justify-items: center;
width: max-content; width: max-content;
@ -117,12 +145,12 @@ html, body {
.hammer-badge { .hammer-badge {
grid-column: 1; grid-column: 1;
width: 18px; width: 12px;
height: 18px; height: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 14px; font-size: 10px;
line-height: 1; line-height: 1;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.55)); filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.55));
/* Keep layout space when the other team has hammer (no collapse / shift). */ /* Keep layout space when the other team has hammer (no collapse / shift). */
@ -134,14 +162,14 @@ html, body {
} }
.stone-chip { .stone-chip {
width: 18px; width: 12px;
height: 18px; height: 12px;
border-radius: 50%; border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.85); border: none;
box-shadow: box-shadow:
inset 0 2px 3px rgba(255, 255, 255, 0.45), inset 0 1px 2px rgba(255, 255, 255, 0.35),
inset 0 -2px 3px rgba(0, 0, 0, 0.35), inset 0 -1px 2px rgba(0, 0, 0, 0.35),
0 1px 2px rgba(0, 0, 0, 0.4); 0 1px 2px rgba(0, 0, 0, 0.35);
transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease; transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease;
} }
@ -158,19 +186,19 @@ html, body {
transform: scale(0.72); transform: scale(0.72);
filter: grayscale(0.6); filter: grayscale(0.6);
box-shadow: none; box-shadow: none;
border-color: rgba(255, 255, 255, 0.25);
} }
/* —— Baseball-style scoreboard matrix —— */ /* —— Baseball-style scoreboard matrix —— */
.scoreboard-baseball-wrap { .scoreboard-baseball-wrap {
display: flex; display: flex;
justify-content: center; justify-content: center;
width: 100%; align-items: center;
flex: 1 1 50%;
min-width: 0;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding: 4px 0 6px; padding: 0;
pointer-events: none; pointer-events: none;
flex-shrink: 0;
} }
.scoreboard-baseball { .scoreboard-baseball {
@ -179,47 +207,49 @@ html, body {
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px; font-size: 9px;
font-weight: 600; font-weight: 600;
line-height: 1.25; line-height: 1.15;
letter-spacing: 0.02em; letter-spacing: 0.02em;
color: #fff; color: #fff;
background: rgba(0, 0, 0, 0.55); background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.28); border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 8px; border-radius: 6px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 10px rgba(0, 0, 0, 0.35); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 8px rgba(0, 0, 0, 0.3);
overflow: hidden; overflow: hidden;
transform: scale(0.92);
transform-origin: center center;
} }
.scoreboard-baseball th, .scoreboard-baseball th,
.scoreboard-baseball td { .scoreboard-baseball td {
min-width: 1.35em; min-width: 1.1em;
padding: 3px 4px; padding: 2px 3px;
text-align: center; text-align: center;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
white-space: nowrap; white-space: nowrap;
} }
.scoreboard-baseball thead th { .scoreboard-baseball thead th {
font-size: 10px; font-size: 8px;
font-weight: 700; font-weight: 700;
color: rgba(255, 255, 255, 0.72); color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
} }
.scoreboard-baseball__corner { .scoreboard-baseball__corner {
min-width: 3.6em; min-width: 2.6em;
border-left: none; border-left: none;
border-top: none; border-top: none;
background: transparent; background: transparent;
} }
.scoreboard-baseball__label { .scoreboard-baseball__label {
min-width: 3.6em; min-width: 2.6em;
text-align: left; text-align: left;
padding-left: 6px; padding-left: 4px;
padding-right: 6px; padding-right: 4px;
font-size: 10px; font-size: 8px;
font-weight: 700; font-weight: 700;
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
@ -410,7 +440,7 @@ html, body {
width: 100%; width: 100%;
} }
#share-row { #share-row, #hud-meta-row #share {
justify-content: center; justify-content: center;
margin-bottom: 4px; margin-bottom: 4px;
} }
@ -468,7 +498,7 @@ html, body {
width: 100%; width: 100%;
} }
#velocity-control label { #velocity-control label { display: none;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@ -622,3 +652,15 @@ html, body {
.velocity-tick--active { .velocity-tick--active {
color: #7dffc0; color: #7dffc0;
} }
.end-modal-card--compact {
max-width: min(360px, 92vw);
text-align: center;
padding: 20px 22px;
}
.end-modal-card--compact h2 {
margin: 0 0 16px;
font-size: 22px;
}