Open Challenge Protocol
A declarative JSON format for expressing fitness challenges across platforms.
Overview
The Open Challenge Protocol (OCP) is a declarative JSON format for expressing fitness challenges. It defines what constitutes success, not how a host application should implement the experience.
A challenge is a structured goal that evaluates rider performance against defined criteria, with optional lifecycle phases, runtime directives, and iteration/loop logic.
Challenges vs Workouts
OCP is specifically designed for Challenges, not Workouts. Understanding this distinction is critical.
Challenges
- Dynamic and goal-oriented: Success is determined by meeting specific criteria
- Logic-based: Built around conditions, thresholds, and exit criteria
- Often indeterminate duration: Many end when you fail to maintain a condition
- Pass/fail or record-breaking: Either you achieve the goal or compete against a personal best
Examples
- Hold 5.0 w/kg for 10 seconds
- Sprint 200m as fast as possible
- Ride as far as possible while keeping HR below zone 4
- Accelerate from standstill to 40 km/h
Workouts
- Structured and predetermined: Follow a fixed sequence of intervals
- Time/duration-based: Intervals have specific durations
- Completion = success: If you finish all intervals, you "completed" the workout
- Training-focused: Designed for physiological adaptation
Not Suitable for OCP
- 5x (30 seconds on / 30 seconds off)
- Tabata: 8x (20 seconds / 10 seconds)
- 1-2-3-2-1 minute ladder intervals
- 2x20 minutes at FTP
Challenge Structure
A challenge is a JSON object with the following top-level fields:
{
"id": "string",
"version": "0.1.0",
"name": "string",
"description": "string (optional)",
"sport": "cycling | running | rowing | any (optional)",
"mode": "record | pass-fail",
"config": { },
"phases": [ ],
"criteria": { },
"loops": [ ],
"directives": [ ],
"record": { },
"completion": { },
"metadata": { }
}Required Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier for this challenge |
version | string | Protocol version (semver) |
name | string | Human-readable challenge name |
phases | array | Ordered list of challenge phases (OR criteria for simple challenges) |
Optional Fields
| Field | Type | Description |
|---|---|---|
description | string | Longer description of the challenge |
sport | string | Sport type (cycling, running, rowing, any) |
mode | string | record (best effort) or pass-fail (binary success). Default: pass-fail |
config | object | Challenge-wide configuration and computed values |
criteria | object | Success (and optional failure) criteria for simple challenges |
loops | array | Repeating evaluation blocks |
directives | array | Runtime behaviors (notifications, suggestions) |
record | object | What metrics to capture as the challenge result |
completion | object | How the challenge ends and what to display |
metadata | object | Display hints, difficulty, tags, etc. |
Challenge Modes
pass-fail (default): Traditional challenge with success/failure criteria. Binary outcome.
record: Best-effort challenge where the goal is to achieve the best possible result. No failure state—the challenge records your performance.
Metrics
Metrics are the canonical data points that challenges evaluate. Host applications map their raw data to these paths.
Metric Path Format
Metrics use dot-notation: category.metric
power.instant → Current power in watts power.wkg → Current power in watts per kilogram lap.index → Current lap number (0-indexed or 1-indexed per host) position.current → Race position (1 = first place)
Core Metrics
Time
| Path | Type | Description |
|---|---|---|
time.elapsed | number | Seconds since challenge started |
time.session | number | Seconds since ride session started |
Power
| Path | Type | Description |
|---|---|---|
power.instant | number | Current power (watts) |
power.avg | number | Average power for session |
power.normalized | number | Normalized power |
power.wkg | number | Current watts per kilogram |
power.zone | number | Current power zone (1-7) |
power.percentFtp | number | Current power as % of FTP |
Speed, Cadence, Heart Rate
| Path | Type | Description |
|---|---|---|
speed.instant | number | Current speed (m/s or km/h per host) |
cadence.instant | number | Current cadence (RPM) |
heartRate.instant | number | Current heart rate (BPM) |
heartRate.zone | number | Current HR zone (1-5) |
Distance, Lap, Track
| Path | Type | Description |
|---|---|---|
distance.total | number | Total distance (meters) |
lap.index | number | Current lap number |
lap.count | number | Total laps completed |
track.length | number | Track length (meters) |
Race/Multiplayer Metrics
These are only available during race contexts:
| Path | Type | Description |
|---|---|---|
position.current | number | Current race position (1 = first) |
gap.ahead | number | Gap to rider ahead |
gap.behind | number | Gap to rider behind |
overtakes.count | number | Number of overtakes made |
Phases
Phases define the structure of a challenge as an ordered sequence of segments. Each phase has its own duration, targets, exit conditions, and directives.
Phase Structure
{
"id": "effort",
"name": "Max Effort",
"duration": 30,
"description": "Give everything for 30 seconds",
"targets": { },
"exitConditions": { },
"directives": [ ]
}| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier for this phase |
name | string | Yes | Display name |
duration | number/string | No | Phase duration in seconds, or "unlimited" |
distance | number | No | Phase distance in meters (alternative to duration) |
targets | object | No | Target metrics for this phase |
exitConditions | object | No | Conditions that end this phase early |
Targets
Targets define the expected or required metrics during a phase:
{
"targets": {
"power": { "min": 80, "max": 150 },
"cadence": { "min": 90, "max": 100 }
}
}Ramping Targets
{
"power": {
"mode": "ramp",
"start": 100,
"increment": 1,
"per": "second"
}
}Conditions
A condition is a single testable expression that evaluates to true or false.
Condition Structure
{
"metric": "power.wkg",
"operator": ">=",
"value": 5.0,
"duration": 10,
"window": "sustained"
}| Field | Type | Required | Description |
|---|---|---|---|
metric | string | Yes | Metric path to evaluate |
operator | string | Yes | Comparison operator |
value | number/array | Yes | Target value(s) |
duration | number | No | Time constraint in seconds |
window | string | No | How duration is evaluated |
Operators
Comparison
==Equal to!=Not equal to>Greater than<Less than>=Greater than or equal<=Less than or equal
Range
betweenInclusive rangeoutsideOutside range
Change
increasedByDelta increasedecreasedByDelta decrease
Duration Windows
| Window | Description |
|---|---|
sustained | Condition must be true continuously for the duration (default) |
cumulative | Condition must be true for total duration (can be non-continuous) |
rolling | Evaluated over a rolling window of the specified duration |
any | Condition must be true at any point for the duration |
Logical Gates
Conditions can be combined using logical gates.
Gate Types
| Gate | Description |
|---|---|
all | All conditions must be true (AND) |
any | At least one condition must be true (OR) |
none | No conditions can be true (NOT ANY) |
sequence | Conditions must become true in order |
Example: Nested Gates
{
"all": [
{ "metric": "time.elapsed", "operator": ">=", "value": 60 },
{
"any": [
{ "metric": "power.wkg", "operator": ">=", "value": 5.0 },
{ "metric": "position.current", "operator": "==", "value": 1 }
]
}
]
}"After 60 seconds, either hit 5.0 w/kg OR be in first place"
Lifecycle
The lifecycle defines when challenge evaluation begins and ends.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ PENDING │ ──▶ │ ARMING │ ──▶ │ ACTIVE │ ──▶ │ COMPLETE │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │
│ ▼
│ ┌─────────────┐
└────────────▶ │ FAILED │
└─────────────┘| Phase | Description |
|---|---|
pending | Challenge loaded but not started |
arming | Waiting for start conditions |
active | Evaluating success/failure criteria |
complete | Success criteria met |
failed | Failure criteria met or abandoned |
Lifecycle Structure
{
"lifecycle": {
"arming": {
"all": [
{ "metric": "time.elapsed", "operator": ">=", "value": 120 }
]
},
"timeout": 600,
"maxAttempts": 3
}
}Success and Failure Criteria
Criteria Structure
{
"criteria": {
"success": {
"all": [
{ "metric": "power.wkg", "operator": ">=", "value": 5.0, "duration": 10 }
]
},
"failure": {
"any": [
{ "metric": "power.instant", "operator": "==", "value": 0, "duration": 5 }
]
}
}
}Success-Only Challenges
Most challenges only define success criteria. The challenge continues until success or user abandonment.
Challenges with Failure Conditions
{
"criteria": {
"success": {
"all": [
{ "metric": "lap.count", "operator": ">=", "value": 5 }
]
},
"failure": {
"any": [
{ "metric": "position.current", "operator": ">", "value": 3 }
]
}
}
}"Complete 5 laps while staying in top 3. Drop to 4th or worse = fail."
Loops
Loops enable repeating evaluation blocks with iteration-aware logic. This is essential for challenges that involve multiple intervals, progressive difficulty, or repeated actions.
Loop Structure
{
"loops": [
{
"id": "sprint_intervals",
"count": 5,
"trigger": {
"metric": "lap.index",
"operator": "increasedBy",
"value": 1
},
"onIteration": {
"directives": [ ],
"criteria": { }
},
"onComplete": {
"directives": [ ]
}
}
]
}Iteration Context Variables
| Variable | Description |
|---|---|
{{loopIndex}} | Current iteration (0-indexed) |
{{loopCount}} | Total iterations configured |
{{loopRemaining}} | Iterations remaining |
Progressive Difficulty Example
{
"loops": [
{
"id": "progressive_power",
"count": 5,
"trigger": {
"metric": "time.elapsed",
"operator": ">=",
"value": "{{loopIndex * 60}}"
},
"onIteration": {
"directives": [
{
"type": "notify",
"message": "Interval {{loopIndex + 1}}/{{loopCount}}: Hold {{3.0 + loopIndex * 0.5}} w/kg"
}
]
}
}
]
}"5 intervals with increasing power: 3.0, 3.5, 4.0, 4.5, 5.0 w/kg"
Directives
Directives are optional runtime behaviors that the host application may execute. They are advisory—the host decides whether to honor them.
Directive Structure
{
"directives": [
{
"id": "gap_warning",
"type": "notify",
"when": {
"metric": "gap.ahead",
"operator": ">",
"value": 3.0
},
"params": {
"message": "Gap widening! Push harder.",
"priority": "high"
},
"cooldown": 10
}
]
}Directive Types
notify
Display a message to the rider
{
"type": "notify",
"params": {
"message": "Push harder!",
"priority": "normal | high | critical"
}
}sound
Play an audio cue
{
"type": "sound",
"params": {
"sound": "bell | chime | warning | success"
}
}suggest
Suggest a target to the rider (advisory)
{
"type": "suggest",
"params": {
"metric": "power.instant",
"target": 250
}
}highlight
Highlight a metric in the UI
{
"type": "highlight",
"params": {
"metric": "power.wkg",
"style": "pulse | glow | flash"
}
}Record (Best Effort Challenges)
For mode: "record" challenges, the record object defines what metrics to capture as the challenge result.
Record Structure
{
"record": {
"primary": {
"metric": "power.max",
"window": 5,
"label": "5-Second Max Power",
"unit": "watts",
"phase": "effort"
},
"secondary": [
{ "metric": "power.peak", "label": "Peak Power", "unit": "watts" },
{ "metric": "cadence.max", "label": "Max Cadence", "unit": "rpm" }
]
}
}Common Primary Metrics
| Challenge Type | Primary Metric | Example |
|---|---|---|
| Max effort | power.max with window | 5-second max power |
| Time trial | duration for phase | 200m sprint time |
| Endurance hold | duration for phase | Time held at target |
| Distance | distance.total | Meters covered |
Completion
The completion object defines how a challenge ends and what to display in the summary.
Completion Structure
{
"completion": {
"condition": "phases-complete | exit-or-phases-complete | criteria-met",
"summary": {
"headline": "{{record.primary.value}}W",
"subheadline": "5-Second Max Power"
}
}
}Completion Conditions
| Condition | Description |
|---|---|
phases-complete | Challenge ends when all phases complete |
exit-or-phases-complete | Challenge ends on phase exit condition OR all phases complete |
criteria-met | Challenge ends when success criteria are met (pass-fail mode) |
Metadata
Metadata provides display hints and categorization for host applications.
{
"metadata": {
"difficulty": 4,
"estimatedDuration": 120,
"tags": ["sprint", "power", "short"],
"author": "Ride Cave",
"icon": "bolt",
"color": "#FF6B00",
"shortDescription": "Hold 5.0 w/kg for 10 seconds",
"requirements": {
"powerMeter": true,
"heartRateMonitor": false
}
}
}| Field | Type | Description |
|---|---|---|
difficulty | number | 1-5 difficulty rating |
estimatedDuration | number | Expected duration in seconds |
tags | array | Categorization tags |
author | string | Challenge creator |
icon | string | Icon identifier |
color | string | Accent color (hex) |
requirements | object | Required sensors/equipment |
Complete Example
{
"id": "power-surge",
"version": "0.1.0",
"name": "Power Surge",
"description": "After a 2-minute warmup, hold 5.0 w/kg for 10 continuous seconds.",
"sport": "cycling",
"lifecycle": {
"arming": {
"all": [
{ "metric": "time.elapsed", "operator": ">=", "value": 120 }
]
},
"timeout": 300
},
"criteria": {
"success": {
"all": [
{
"metric": "power.wkg",
"operator": ">=",
"value": 5.0,
"duration": 10,
"window": "sustained"
}
]
}
},
"directives": [
{
"type": "notify",
"params": {
"message": "Warmup complete. Go for 5.0 w/kg!"
},
"once": true
},
{
"type": "notify",
"when": {
"metric": "power.wkg",
"operator": ">=",
"value": 4.5
},
"params": {
"message": "Almost there! Push to 5.0!",
"priority": "high"
},
"cooldown": 5
}
],
"metadata": {
"difficulty": 4,
"estimatedDuration": 180,
"tags": ["sprint", "power", "short"],
"author": "Ride Cave",
"shortDescription": "Hold 5.0 w/kg for 10 seconds"
}
}Evaluation Model
Evaluation Loop
The challenge evaluator runs at a fixed frequency (recommended: 10Hz).
┌─────────────────────────────────────────────────────────────┐ │ EVALUATION TICK │ ├─────────────────────────────────────────────────────────────┤ │ 1. Collect current metrics from host │ │ 2. Check lifecycle phase │ │ - If PENDING: check arming conditions │ │ - If ARMING: check arming conditions → transition │ │ - If ACTIVE: evaluate criteria + loops + directives │ │ 3. Update loop states │ │ 4. Check success criteria │ │ 5. Check failure criteria (if defined) │ │ 6. Fire applicable directives │ │ 7. Return evaluation result │ └─────────────────────────────────────────────────────────────┘
Evaluation Result
{
"phase": "active",
"success": false,
"failed": false,
"progress": {
"percent": 75,
"conditions": [
{
"metric": "power.wkg",
"target": 5.0,
"current": 5.2,
"durationElapsed": 7.5,
"durationRequired": 10,
"met": false
}
]
},
"loops": [
{
"id": "sprint_intervals",
"iteration": 2,
"count": 5,
"status": "active"
}
],
"firedDirectives": ["gap_warning"],
"elapsed": 145.3
}Appendices
A. Environment & Portability
Challenges may reference environment metrics—values provided by the host application that describe the context in which the challenge runs.
Portability Spectrum
- Fully Portable: Challenges that only reference core metrics (power, cadence, heart rate, time, distance) work on any compliant host
- Environment-Dependent: Challenges that reference environment metrics require the host to provide those values
- Platform-Specific: Some challenges may reference features unique to a specific platform
Fallback Values
{
"config": {
"approachDistance": {
"value": "{{track.length}}",
"fallback": 250
}
}
}B. Reserved Metric Paths
The following paths are reserved for future use:
rowing.*- Rowing-specific metricsrunning.*- Running-specific metricsstrength.*- Strength training metricscustom.*- Host-defined custom metrics
C. Host Implementation Notes
Hosts must implement a metric adapter that maps their internal data to OCP metric paths:
interface MetricAdapter {
resolve(path: string): number | undefined;
getProfile(): { ftp?: number; weight?: number; maxHr?: number };
}D. Version History
| Version | Date | Changes |
|---|---|---|
| 0.1.0-draft | 2025-01 | Initial draft specification |
