Disclaimer: This blog post is automatically generated from project documentation and technical proposals using AI assistance. The content represents our development journey and architectural decisions. Code examples are simplified illustrations and may not reflect the exact production implementation.

The Biometric Challenge

Caroline had a question that changed everything: “What if we could track heart rate during pressure points?”

I looked up from my keyboard. “You mean correlate biometric data with match events?”

“Exactly,” she said. “We already track every shot, every score change. If we add heart rate and movement data, we could see exactly when players get stressed.”

Claude liked the idea: “Biometric data would let you detect fatigue patterns, recovery rates, momentum shifts—all based on actual physiology, not just scores.”

We needed a system that could handle high-frequency data (up to 50Hz for gyroscopes), stream it in real-time from wearables, and correlate it with our existing event sourcing system.

System Design: Five Sensors, Four Players

Caroline sketched the architecture:

Wearables → WebSocket → NATS → ClickHouse → Analytics

We decided to track five biometric sensors per player:

  1. Heart Rate (1 Hz): BPM, HRV, confidence
  2. Accelerometer (10 Hz): 3-axis movement intensity
  3. Gyroscope (50 Hz): Rotation and angular velocity
  4. Blood Oxygen (1 Hz): SpO2 percentage
  5. Derived Metrics: Movement intensity, rotation magnitude

“That’s 248 messages per second across 4 players,” Claude calculated. “You need high-throughput infrastructure.”

Data Volume Estimates

Sensor Frequency Players msg/sec
Heart Rate 1 Hz 4 4
Accelerometer 10 Hz 4 40
Gyroscope 50 Hz 4 200
SpO2 1 Hz 4 4
Total - - 248 msg/sec

For a 1-hour match, that’s 892,800 raw data points. Compressed with ClickHouse, about 12-25 MB per match.

WebSocket Architecture: Player-Specific Paths

Caroline proposed a clever WebSocket design:

/matches/:matchId/player/:playerId/biometrics

“Each player gets their own WebSocket path,” she explained. “That way the path dictates the player identity—no spoofing.”

I asked: “Why not have one WebSocket and include playerId in the payload?”

“Three reasons,” Claude answered:

  1. Security: Path-based routing prevents playerId spoofing in payloads
  2. Debugging: One connection per player makes logs clearer
  3. Load distribution: Can route different players to different servers

Caroline added: “Plus, if a player’s device disconnects, only their stream is affected. The other 3 keep working.”

Payload Structure

We designed a JSON payload with all five sensors:

{
  "matchId": "01JDQRST12345678901234ABCD",
  "playerId": 1,
  "timestamp": "2025-11-30T14:32:15.123Z",
  "heartRate": {
    "bpm": 145,
    "confidence": 0.98,
    "hrv_rmssd": 32.5,
    "sensor": "polar-h10"
  },
  "accelerometer": {
    "x": 0.45,
    "y": -0.32,
    "z": 9.81,
    "magnitude": 9.83,
    "sampleRate": 10
  },
  "gyroscope": {
    "x": 1.2,
    "y": -0.8,
    "z": 0.3,
    "sampleRate": 50
  },
  "spO2": {
    "percentage": 97,
    "confidence": 0.95
  }
}

Caroline noted: “We include confidence scores so we can filter out bad sensor readings later.”

NATS Stream Design: Subject Hierarchy

Claude recommended a dedicated NATS stream for biometrics:

nats stream add biometrics \
  --subjects="biometrics.>" \
  --retention=limits \
  --max-age=7d \
  --max-msgs-per-subject=100000 \
  --storage=file

The subject hierarchy follows this pattern:

biometrics.{matchId}.{playerId}

For example:

biometrics.01JDQRST12345678901234ABCD.1  # Player 1
biometrics.01JDQRST12345678901234ABCD.2  # Player 2
biometrics.01JDQRST12345678901234ABCD.3  # Player 3
biometrics.01JDQRST12345678901234ABCD.4  # Player 4

“This lets us subscribe to specific players,” Caroline explained. “If we want Player 1’s data for replay analysis, we just filter by biometrics.{matchId}.1.”

I asked: “Why a separate stream from match events?”

Claude answered: “Three reasons”:

  1. Different retention policies: Match events are kept forever; biometrics only 7 days
  2. Independent scaling: High-frequency biometric data shouldn’t slow down match event processing
  3. Isolated failures: If the biometric consumer crashes, match event processing continues

ClickHouse Schema: Raw and Aggregated

We created two ClickHouse tables:

Raw Biometrics Table

CREATE TABLE shock.biometrics (
  match_id String,
  player_id UInt8,
  event_timestamp DateTime64(3),

  -- Heart rate
  heart_rate_bpm UInt16,
  heart_rate_confidence Float32,
  heart_rate_hrv_rmssd Float32,
  heart_rate_sensor String,

  -- Accelerometer
  accel_x Float32,
  accel_y Float32,
  accel_z Float32,
  accel_magnitude Float32,

  -- Gyroscope
  gyro_x Float32,
  gyro_y Float32,
  gyro_z Float32,

  -- SpO2
  spo2_percentage UInt8,
  spo2_confidence Float32,

  created_at DateTime64(3) DEFAULT now64(3)

) ENGINE = ReplacingMergeTree(created_at)
PARTITION BY (match_id, toYYYYMMDD(event_timestamp))
ORDER BY (match_id, player_id, event_timestamp);

Caroline explained: “We use ReplacingMergeTree with created_at as the version column for deduplication.”

Aggregated View (1-Second Windows)

For performance, we pre-aggregate high-frequency data:

CREATE MATERIALIZED VIEW shock.biometrics_1s_agg
ENGINE = AggregatingMergeTree()
PARTITION BY (match_id, toYYYYMMDD(event_timestamp))
ORDER BY (match_id, player_id, event_timestamp)
AS
SELECT
  match_id,
  player_id,
  toStartOfSecond(event_timestamp) as event_timestamp,

  -- Heart rate (1Hz, use latest)
  argMax(heart_rate_bpm, event_timestamp) as heart_rate_bpm,
  argMax(heart_rate_hrv_rmssd, event_timestamp) as heart_rate_hrv_rmssd,

  -- Accelerometer (10Hz → 1Hz aggregation)
  avg(accel_magnitude) as accel_magnitude_avg,
  max(accel_magnitude) as accel_magnitude_max,
  stddevPop(accel_magnitude) as accel_magnitude_stddev,

  -- Gyroscope (50Hz → 1Hz aggregation)
  avg(sqrt(pow(gyro_x, 2) + pow(gyro_y, 2) + pow(gyro_z, 2))) as rotation_magnitude,

  -- Derived: Movement intensity (gravity-compensated)
  avg(sqrt(pow(accel_x, 2) + pow(accel_y, 2) + pow(accel_z - 9.81, 2))) as movement_intensity,

  -- SpO2 (1Hz, use latest)
  argMax(spo2_percentage, event_timestamp) as spo2_percentage

FROM shock.biometrics
GROUP BY match_id, player_id, toStartOfSecond(event_timestamp);

“This reduces query data from 248 rows/sec to 1 row/sec per player,” Caroline said. “Queries are 248x faster.”

Building the Biometric Simulator

Before integrating real wearables, we needed a way to test. Caroline built a biometric mock component that autonomously generates realistic data.

Realistic Data Generation

The simulator models physiological patterns:

class BiometricDataGenerator {
  generateHeartRate(): number {
    const baseHR = this.player.restingHR;
    const intensityDelta = this.player.intensity * (this.player.maxHR - baseHR);
    const fatigueDelta = this.player.fatigueLevel * 15; // Fatigue increases HR
    const noise = (Math.random() - 0.5) * 6; // ±3 bpm natural variation

    // HR follows intensity with 2-3 second lag
    const lagFactor = Math.exp(
      -(Date.now() - this.player.lastIntensityChange) / 2000
    );

    return Math.round(
      baseHR + intensityDelta * (1 - lagFactor) + fatigueDelta + noise
    );
  }
}

Caroline explained the key features:

  1. Heart Rate Lag: HR increases 2-3 seconds after movement starts (realistic physiological delay)
  2. Recovery Curve: Exponential decay—HR drops 20-30 bpm/min after intensity drops
  3. Fatigue Simulation: Resting HR gradually increases (+5 bpm per 15 minutes)
  4. Natural Variation: ±3 bpm noise, breathing cycles

Autonomous Random Events

The simulator triggers random events every 8-20 seconds:

  • Spike: Random player gets sudden HR jump (+20 bpm) for 2-4 seconds
  • Recovery: All players return to baseline over 5-10 seconds
  • Fatigue: Random player accumulates fatigue, then recovers

“This creates realistic match dynamics without any manual input,” Caroline said. “Just hit Start and watch.”

Live Visualization

The component displays 5 real-time charts (60-second rolling window):

┌─────────────────────────────────────────────────────────┐
│ 🫀 Biometric Simulator                                  │
├─────────────────────────────────────────────────────────┤
│ [Heart Rate Chart]  [HRV Chart]  [Accel Chart]          │
│ [Gyro Chart]        [SpO2 Chart]                        │
├─────────────────────────────────────────────────────────┤
│ P1: 145 bpm ↑  P2: 132 bpm →  P3: 128 bpm ↓  P4: 141   │
├─────────────────────────────────────────────────────────┤
│ [Start Simulation]        [Stop Simulation]             │
└─────────────────────────────────────────────────────────┘

Caroline optimized the rendering:

  • Split data generation (1Hz) from chart updates (500ms)
  • Direct array reference assignment (no spreading)
  • Chart update mode set to ’none’ (no animation overhead)

“We were hitting setInterval performance warnings,” she said. “Now it runs smoothly even with 5 charts.”

Query Enhancements: Correlating Biometrics with Events

The real power comes from correlating biometric data with match events. We enhanced three existing queries:

1. Pressure Analysis + Heart Rate Variance

We wanted to answer: “Does heart rate spike during pressure points?”

// Simplified query logic
WITH pressure_points AS (
  SELECT
    match_id,
    event_timestamp,
    player,
    situation_type -- GAME_POINT, DEUCE, ADVANTAGE
  FROM shock.score_changes
  WHERE situation_type IN ('GAME_POINT', 'DEUCE', 'ADVANTAGE')
),
correlated_biometrics AS (
  SELECT
    pp.player,
    pp.situation_type,

    -- Get biometric data within ±5 seconds of event
    b.heart_rate_bpm,
    b.movement_intensity,

    -- Calculate HRV over 30-second window
    stddevPop(b.heart_rate_bpm) OVER (
      PARTITION BY pp.match_id, pp.player
      ORDER BY b.event_timestamp
      RANGE BETWEEN 30 PRECEDING AND CURRENT ROW
    ) as heart_rate_variance

  FROM pressure_points pp
  ASOF LEFT JOIN shock.biometrics_1s_agg b
    ON pp.match_id = b.match_id
    AND pp.player = b.player_id
    AND b.event_timestamp <= pp.event_timestamp
)
SELECT
  player,
  situation_type,
  avg(heart_rate_bpm) as avg_bpm,
  avg(heart_rate_variance) as avg_hrv
FROM correlated_biometrics
GROUP BY player, situation_type;

Claude explained: “We use ClickHouse’s ASOF JOIN to correlate time-series data. It finds the closest biometric reading within ±5 seconds of each event.”

2. Momentum Analysis + Movement Intensity

We added movement intensity to momentum charts:

interface MomentumChartData {
  // Existing fields
  timeWindow: number;
  player: number;
  playerMomentumPct: number;

  // NEW: Biometric correlation
  biometrics?: {
    avgIntensity: number; // Average movement (m/s²)
    avgHeartRate: number; // Average BPM
    maxAcceleration: number; // Peak movement spike
  };
}

“Now we can see if momentum shifts correlate with physical fatigue,” Caroline said. “If movement intensity drops but momentum is high, maybe the player is tired but winning on skill alone.”

3. Fatigue Detection (New Query)

We created a dedicated fatigue analysis query:

interface FatigueDataPoint {
  timeWindow: number; // Minute of match
  player: number;
  fatigueScore: number; // 0-100 (0=fresh, 100=exhausted)
  restingHeartRate: number; // BPM during low-intensity periods
  recoveryRate: number; // BPM drop per minute after peaks
  movementDecline: number; // % decline vs first 5 minutes
}

The fatigue score algorithm:

fatigue_score = (
  0.4 × normalized_resting_hr +          // Higher resting HR = tired
  0.3 × (1 - normalized_recovery_rate) + // Slower recovery = tired
  0.2 × normalized_movement_decline +    // Less movement = tired
  0.1 × normalized_intensity_variance    // Inconsistent = tired
) × 100

Claude noted: “This is a composite metric. We’re not just looking at one signal—we’re combining multiple physiological indicators.”

Performance Considerations

Caroline was concerned about query latency: “Won’t joining biometric data slow down our existing queries?”

We profiled the enhanced pressure analysis query:

Query Version P95 Latency Data Scanned
Original (no biometrics) 50ms 5,000 rows
Enhanced (with biometrics) 80ms 8,500 rows
Impact +60% +70%

Claude suggested optimizations:

  1. Partition pruning: Only scan biometric data for the specific matchId
  2. Materialized views: Use pre-aggregated 1-second data instead of raw 50Hz data
  3. Indexes: Add minmax indexes on heart_rate_bpm and accel_magnitude

After optimization:

Query Version P95 Latency
Enhanced (optimized) 62ms
Impact +24%

“That’s acceptable,” Caroline said. “A 24% latency increase for massive analytical gains.”

Lessons Learned

After implementing the biometric system, we reflected on what worked:

What Worked Well

  1. Player-specific WebSocket paths - Eliminated payload spoofing, simplified debugging
  2. Separate NATS stream - Isolated high-frequency biometric data from match events
  3. Materialized views - 248x query speedup by pre-aggregating to 1-second windows
  4. Autonomous simulator - Could test without real hardware
  5. ASOF JOIN - ClickHouse’s time-series join made event correlation elegant

Challenges

  1. Multi-frequency data - Had to handle 1Hz (HR), 10Hz (accel), and 50Hz (gyro) in the same pipeline
  2. Chart performance - Initial implementation had setInterval violations; had to split data generation from rendering
  3. Query complexity - ASOF JOIN with window functions was tricky to get right
  4. Storage planning - 892,800 rows per match required careful partition strategy

Scalability Impact

Metric Before Biometrics After Biometrics Impact
NATS msg/sec ~5 ~50 +10x
ClickHouse inserts/sec ~5 ~50 +10x
Storage per match 1 MB 4 MB +4x
Query latency (P95) 50ms 62ms +24%

Caroline’s verdict: “The infrastructure handled it well. We scaled up 10x message throughput with no bottlenecks.”

Future Enhancements

Claude had ideas for Phase 2:

Real Wearable Integration

  • Polar H10 heart rate monitors (Bluetooth LE)
  • Apple Watch integration (via iOS app)
  • Garmin devices streaming

Advanced Analytics

  • Stress Recovery Index: Compare pre-match vs post-match HRV
  • VO₂ Max Estimation: Calculate aerobic capacity from HR recovery curves
  • Injury Risk Detection: Detect asymmetric movement patterns in accelerometer data
  • Training Load: Cumulative stress score across multiple matches

Machine Learning

  • Fatigue Prediction: Predict performance decline 5 minutes ahead
  • Optimal Substitution: Recommend player swaps based on fatigue scores
  • Personalized Baselines: Learn player-specific resting HR and recovery rates

Caroline liked the ML ideas: “We could train a model to predict when a player is about to make errors based on heart rate patterns.”

Mermaid Diagram: Biometric Data Flow

graph TB
    subgraph Wearables["📱 Wearable Devices"]
        P1[Player 1<br/>Polar H10]
        P2[Player 2<br/>Apple Watch]
        P3[Player 3<br/>Garmin]
        P4[Player 4<br/>Polar H10]
    end

    subgraph WebSocket["📡 WebSocket Layer"]
        WS1["matches/:matchId/player/1/biometrics"]
        WS2["matches/:matchId/player/2/biometrics"]
        WS3["matches/:matchId/player/3/biometrics"]
        WS4["matches/:matchId/player/4/biometrics"]
    end

    subgraph NATS["📨 NATS JetStream"]
        STREAM[biometrics stream<br/>248 msg/sec]
        SUBJ1[biometrics.matchId.1]
        SUBJ2[biometrics.matchId.2]
        SUBJ3[biometrics.matchId.3]
        SUBJ4[biometrics.matchId.4]
    end

    subgraph Consumer["🔄 Projection Service"]
        CONSUMER[Biometric Consumer<br/>Batch processor]
    end

    subgraph ClickHouse["💾 ClickHouse"]
        RAW[(biometrics table<br/>Raw 50Hz data)]
        AGG[(biometrics_1s_agg<br/>Aggregated 1Hz)]
    end

    subgraph Analytics["📊 Enhanced Queries"]
        PRESSURE[Pressure Analysis<br/>+ HR variance]
        MOMENTUM[Momentum Analysis<br/>+ Movement intensity]
        FATIGUE[Fatigue Detection<br/>New metric]
    end

    P1 -->|1-50 Hz| WS1
    P2 -->|1-50 Hz| WS2
    P3 -->|1-50 Hz| WS3
    P4 -->|1-50 Hz| WS4

    WS1 --> SUBJ1
    WS2 --> SUBJ2
    WS3 --> SUBJ3
    WS4 --> SUBJ4

    SUBJ1 --> STREAM
    SUBJ2 --> STREAM
    SUBJ3 --> STREAM
    SUBJ4 --> STREAM

    STREAM --> CONSUMER
    CONSUMER --> RAW
    RAW -.->|Auto-aggregate| AGG

    AGG --> PRESSURE
    AGG --> MOMENTUM
    AGG --> FATIGUE

    classDef wearable fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    classDef websocket fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    classDef nats fill:#fce4ec,stroke:#c2185b,stroke-width:2px
    classDef consumer fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
    classDef storage fill:#fff9c4,stroke:#f57f17,stroke-width:2px
    classDef analytics fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px

    class P1,P2,P3,P4 wearable
    class WS1,WS2,WS3,WS4 websocket
    class STREAM,SUBJ1,SUBJ2,SUBJ3,SUBJ4 nats
    class CONSUMER consumer
    class RAW,AGG storage
    class PRESSURE,MOMENTUM,FATIGUE analytics

Takeaways

  1. Multi-frequency data requires careful aggregation - Pre-aggregate 50Hz data to 1Hz for query performance.
  2. ASOF JOIN is perfect for time-series correlation - ClickHouse makes event correlation elegant.
  3. Player-specific WebSocket paths prevent spoofing - Path-based routing is more secure than payload validation.
  4. Separate NATS streams for different data types - Isolate high-frequency biometric data from match events.
  5. Autonomous simulators accelerate development - Test without real hardware using realistic data generation.

Caroline summed it up: “This system lets us answer questions we couldn’t even ask before. Like: Does heart rate spike before errors? Do players get physically tired before their performance drops?”

Claude agreed: “You’ve built the foundation for sports science analytics. The next step is real wearables and machine learning.”

I was just excited to see the first live heart rate chart during a real match.