F201v7 — In-Race Horse Position Randomness (Sinusoidal Oscillation)
Jira: HEDGE-223 Phase: 3 (UI & Presentation) Status: ✅ Implemented (V7 only)Business Rule
A horse race looks more alive when horses jostle for position rather than sliding smoothly to a fixed spot. Each active horse must continuously oscillate around its probability-derived target position, creating organic movement that reflects the uncertainty of a live race. The oscillation must never change the rank order of horses — a horse with a higher win probability must always appear further right than a horse with a lower probability, even at the extremes of each horse’s drift range. Eliminated horses (scroll-off state) do not oscillate.Technical Design
Base Position
Each horse’s base (target) position is computed byprobToPx(p, fi) — a logarithmic
mapping of percentWin (0–1) to a pixel x-position on the track:
At RIVER end, positions are overridden to a compact 200px range just right of the TURN card,
ranked by hand strength.
Sinusoidal Noise
Each horse has its ownhorseOscParams[i] object, initialised at game load with random
amplitudes and phase offsets across 3 sinusoidal components:
t is elapsed race time in seconds. The 3-component sum produces an irregular, aperiodic
waveform that avoids mechanical-looking periodicity.
Rank-Order Clamping
After each horse’s oscillating position is computed, a clamping pass runs in rank order (highest probability first) to ensure no horse overtakes its neighbour:OSC_MIN_GAP = 20px. This guarantees the visual rank order always matches the probability rank,
regardless of oscillation extremes.
rAF Lerp Drift Loop
Horses do not use CSStransition: left. Instead, a requestAnimationFrame loop runs
continuously while raceStarted=true and raceEnded=false. Each frame:
- Compute oscillating target position for every active horse
- Apply rank-order clamping
- Lerp each horse’s current pixel position toward its clamped target:
- Set
horseEl.style.left = current + 'px'
percentWin updates between stages,
providing an organic deceleration into the new base position rather than a snap.
Active / Inactive Guard
- Oscillation only runs while
raceStarted=trueANDraceEnded=false - Eliminated horses (
horseScrollingOff[i]=true) are excluded from the oscillation loop; they travel left atTRACK_PX_PER_SECindependently until off-screen - At HOLE (before first Run click),
raceStarted=false— horses are stationary
Key Constants
Acceptance Criteria
AC1. While the race is active (raceStarted=true, raceEnded=false), every active horse
continuously changes its horizontal position in a non-linear, irregular pattern — it must not
travel in a straight line or exhibit a perfectly regular period.
AC2. The oscillation amplitude must not exceed OSC_AMP_FRAC × trackWidth (4% of track
width) from each horse’s current probability target position.
AC3. At no point during oscillation does a horse with a lower percentWin appear to the
right of a horse with a higher percentWin; the visual rank order always matches the
probability rank order.
AC4. The minimum visual gap between any two adjacent oscillating horses is at least
OSC_MIN_GAP (20px) at all times.
AC5. Eliminated horses (horseScrollingOff[i]=true) do not participate in the sinusoidal
oscillation loop; they move leftward at a constant scroll speed.
AC6. At HOLE (before the first Run click), horses are stationary — oscillation is inactive.
AC7. When percentWin values update at a new stage, horses lerp smoothly to their new
base positions rather than snapping; the transition appears organic, not mechanical.