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:
- Heart Rate (1 Hz): BPM, HRV, confidence
- Accelerometer (10 Hz): 3-axis movement intensity
- Gyroscope (50 Hz): Rotation and angular velocity
- Blood Oxygen (1 Hz): SpO2 percentage
- 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:
- Security: Path-based routing prevents playerId spoofing in payloads
- Debugging: One connection per player makes logs clearer
- 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”:
- Different retention policies: Match events are kept forever; biometrics only 7 days
- Independent scaling: High-frequency biometric data shouldn’t slow down match event processing
- 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:
- Heart Rate Lag: HR increases 2-3 seconds after movement starts (realistic physiological delay)
- Recovery Curve: Exponential decay—HR drops 20-30 bpm/min after intensity drops
- Fatigue Simulation: Resting HR gradually increases (+5 bpm per 15 minutes)
- 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:
- Partition pruning: Only scan biometric data for the specific
matchId - Materialized views: Use pre-aggregated 1-second data instead of raw 50Hz data
- Indexes: Add minmax indexes on
heart_rate_bpmandaccel_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
- Player-specific WebSocket paths - Eliminated payload spoofing, simplified debugging
- Separate NATS stream - Isolated high-frequency biometric data from match events
- Materialized views - 248x query speedup by pre-aggregating to 1-second windows
- Autonomous simulator - Could test without real hardware
- ASOF JOIN - ClickHouse’s time-series join made event correlation elegant
Challenges
- Multi-frequency data - Had to handle 1Hz (HR), 10Hz (accel), and 50Hz (gyro) in the same pipeline
- Chart performance - Initial implementation had setInterval violations; had to split data generation from rendering
- Query complexity - ASOF JOIN with window functions was tricky to get right
- 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
- Multi-frequency data requires careful aggregation - Pre-aggregate 50Hz data to 1Hz for query performance.
- ASOF JOIN is perfect for time-series correlation - ClickHouse makes event correlation elegant.
- Player-specific WebSocket paths prevent spoofing - Path-based routing is more secure than payload validation.
- Separate NATS streams for different data types - Isolate high-frequency biometric data from match events.
- 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.