> ## Documentation Index
> Fetch the complete documentation index at: https://hedgeem-api.qeetoto.com/llms.txt
> Use this file to discover all available pages before exploring further.

# F201v7 — In-Race Horse Position Randomness (Sinusoidal Oscillation)

> HEDGE-223: Active horses continuously drift around their probability-target position using per-horse sinusoidal noise, while preserving rank order.

# 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 by `probToPx(p, fi)` — a logarithmic
mapping of `percentWin` (0–1) to a pixel x-position on the track:

```
norm = log(1 + p × 9) / log(10)
```

| Stage            | Left boundary      | Right boundary     |
| ---------------- | ------------------ | ------------------ |
| HOLE, FLOP, TURN | 20% of track width | 80% of track width |
| RIVER            | 20% of track width | 90% of track width |

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 own `horseOscParams[i]` object, initialised at game load with random
amplitudes and phase offsets across 3 sinusoidal components:

```typescript theme={null}
horseOscParams[i] = {
  a:  [rand(0.4, 1.0), rand(0.3, 0.7), rand(0.2, 0.5)],  // amplitudes (fraction)
  f:  [rand(0.4, 0.9), rand(0.9, 1.8), rand(1.8, 3.5)],  // frequencies (Hz)
  ph: [rand(0, 2π),    rand(0, 2π),    rand(0, 2π)],       // phase offsets (radians)
}
```

At each rAF frame the raw oscillation offset is:

```
rawOffset = OSC_AMP_FRAC × trackWidth
            × Σ( a[k] × sin(2π × f[k] × t + ph[k]) )  for k in {0,1,2}
```

where `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:

```
for i from highest-prob to lowest-prob:
    if pos[i] < pos[i-1] + OSC_MIN_GAP:
        pos[i] = pos[i-1] + OSC_MIN_GAP
```

`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 CSS `transition: left`. Instead, a `requestAnimationFrame` loop runs
continuously while `raceStarted=true` and `raceEnded=false`. Each frame:

1. Compute oscillating target position for every active horse
2. Apply rank-order clamping
3. Lerp each horse's current pixel position toward its clamped target:
   ```
   current = current + (target - current) × horseLerpFactor × dt
   ```
4. Set `horseEl.style.left = current + 'px'`

The lerp factor smooths out abrupt target changes when `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=true` AND `raceEnded=false`
* Eliminated horses (`horseScrollingOff[i]=true`) are excluded from the oscillation loop;
  they travel left at `TRACK_PX_PER_SEC` independently until off-screen
* At HOLE (before first Run click), `raceStarted=false` — horses are stationary

***

## Key Constants

| Constant               | Value               | Description                                                                       |
| ---------------------- | ------------------- | --------------------------------------------------------------------------------- |
| `OSC_AMP_FRAC`         | `0.04`              | Oscillation half-range as a fraction of track width (e.g. 40px on a 1000px track) |
| `OSC_MIN_GAP`          | `20px`              | Minimum enforced gap between adjacent oscillating horses (rank-order clamp)       |
| `horseLerpFactor`      | e.g. `0.5`          | Lerp smoothing factor applied each rAF frame (tuned for organic deceleration)     |
| `HOLE/FLOP/TURN range` | 20%–80% track width | `probToPx` output range for non-RIVER stages                                      |
| `RIVER range`          | 20%–90% track width | `probToPx` output range at RIVER stage                                            |

***

## 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.
