
Immersive Smart Training Application
Unity • Wahoo KICKR CORE • Bluetooth LE
HOME
NORRA is an immersive indoor cycling simulator built with Unity 2022.3. It connects to a Wahoo KICKR smart trainer and COROS heart rate monitor via Bluetooth LE — turning a real indoor bike into a virtual road cycling experience. The faster you pedal, the faster you ride.
Key Features
- Bluetooth LE connection to Wahoo KICKR and COROS HRM simultaneously
- Automatic bike route following driven by real cycling speed
- Memory video overlays triggered by location checkpoints
- Dynamic music playlist with GTA-style album art song cards
- Real-time HUD showing power, cadence, speed, heart rate and training zone
- Mini-map with real-time route progress tracking
- Elevation simulation adjusting KICKR resistance on hills
- Zwift-style loading screen with live device connection status
- Full ride summary card with power and HR chart on route completion
Technology Stack
| Component | Technology | Version |
|---|---|---|
| Game Engine | Unity | 2022.3 LTS |
| Language | C# | .NET Standard 2.1 |
| Bluetooth | BluetoothLEHardwareInterface | Shatalmic |
| UI | Unity uGUI + TextMeshPro | Built-in |
| Video | Unity VideoPlayer + RenderTexture | Built-in |
| Bike Physics | Simple Bicycle Physics | Asset Store |
| Platform | macOS | Mac Silicon |
File Structure
Assets/ Scripts/ KickrReader.cs WorkoutBuilder.cs KickrHUD.cs BikeRouteFollower.cs WaypointRoute.cs MemoryFadeSystem.cs MemoryCheckpoint.cs MiniMap.cs ElevationSystem.cs MusicPlayer.cs LoadingScreen.cs RideSummary.cs Editor/ WaypointRouteEditor.cs StreamingAssets/ ride1_h264.mp4 loading_bg_h264.mp4 Music/ *.mp3 / *.wav Scenes/ main_scene.unity
Quick Start Guide
Requirements
- Mac computer with Bluetooth LE support
- Unity 2022.3 LTS or newer
- Wahoo KICKR smart trainer
- COROS heart rate monitor (optional)
- MP4 video files encoded in H.264 baseline profile
- MP3 or WAV audio files for music playlist
Setup Steps
- Open the Project — Open Unity Hub, click Add, navigate to the NORRA folder. Select Unity 2022.3 as the editor version.
- Enable Bluetooth Permissions — Go to Edit → Project Settings → Player → Mac → Other Settings and enable Bluetooth. Also open Mac System Settings → Privacy & Security → Bluetooth and add Unity to the allowed apps list.
- Add Video Files — Place H.264 MP4 files in Assets/StreamingAssets/. If your videos are in HEVC or MOV format, convert them using the command below.
- Add Music Files — Place MP3 or WAV files in Assets/Music/. Drag them into the MusicPlayer component Songs array in the Inspector.
- Set Up the Route — Select the Route object in the Hierarchy. Add WaypointRoute component. Use Shift+Click in Scene view to place waypoints along the road.
- Press Play — The loading screen appears and automatically scans for your KICKR and HRM. Once connected it fades away and the ride begins.
Converting Videos
Unity on Mac requires H.264 MP4 with baseline profile and constant frame rate. Convert any video using Terminal:
ffmpeg -i input.mp4 -c:v libx264 -pix_fmt yuv420p -profile:v baseline -level 3.0 -vf fps=30 -c:a aac output_h264.mp4
To rotate portrait phone videos to landscape:
ffmpeg -i input.mp4 -vf transpose=2 -c:v libx264 -pix_fmt yuv420p -c:a aac output_landscape.mp4
Installing Homebrew and ffmpeg
If you don’t have Homebrew installed, open Terminal and run:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
Then install ffmpeg:
brew install ffmpeg
SCRIPT REFERENCE
All 12 custom C# scripts are located in Assets/Scripts/. Each script is documented below with its Inspector fields and public methods.
KickrReader.cs
Core Bluetooth LE manager. Scans for and connects to both the Wahoo KICKR trainer and COROS HRM simultaneously using a single Bluetooth LE initialization. Parses incoming BLE data packets and exposes real-time metrics as public properties.
Public Properties:
| Property | Type | Description |
|---|---|---|
| Power | float | Current power output in watts |
| Cadence | float | Current cadence in RPM |
| Speed | float | Current speed in km/h |
| HeartRate | int | Current heart rate in BPM |
| Connected | bool | True when KICKR is connected |
| HRMConnected | bool | True when HRM is connected |
| Status | string | Current KICKR connection status message |
| HRMStatus | string | Current HRM connection status message |
Inspector Fields:
| Field | Type | Description |
|---|---|---|
| connectionStatus | TMP_Text | Shows KICKR status in UI |
| hrmText | TMP_Text | Shows HR and zone in UI |
| hrmStatusText | TMP_Text | Shows HRM connection status |
| z1Max — z4Max | int | BPM thresholds for HR zones Z1-Z5 |
Public Methods:
- SetTargetPower(int watts) — Sends ERG resistance command to KICKR
BLE UUIDs:
| Device | Service UUID | Characteristic UUID |
|---|---|---|
| KICKR Data | 1826 | 2AD2 |
| KICKR ERG Control | 1826 | 2AD9 |
| Heart Rate Monitor | 180D | 2A37 |
WorkoutBuilder.cs
Controls the workout HUD showing live power, cadence, speed and zone with color coding. Manages structured workout intervals sending target power commands to the KICKR automatically.
Inspector Fields:
| Field | Type | Description |
|---|---|---|
| kickr | KickrReader | Data source |
| bikeData | TMP_Text | Main HUD text |
| workoutStatus | TMP_Text | Interval info display |
| powerSlider | Slider | Manual power target |
| intervals | List of WorkoutInterval | ERG workout intervals |
Public Methods:
- StartWorkout() — Begins structured workout interval sequence
- StopWorkout() — Stops workout and sets power to 0
KickrHUD.cs
Optional secondary HUD component. Controls individual TMP_Text objects for power, cadence, speed and zone with configurable font sizes set directly from the Inspector without changing code.
Inspector Fields:
| Field | Type | Description |
|---|---|---|
| kickr | KickrReader | Data source |
| powerText | TMP_Text | Power display |
| cadenceText | TMP_Text | Cadence display |
| speedText | TMP_Text | Speed display |
| zoneText | TMP_Text | Zone name display |
| powerFontSize | float | Power number font size (default 120) |
BikeRouteFollower.cs
Moves the Bicycle Standard object along WaypointRoute automatically. Speed comes from KickrReader.Speed. Tilts naturally on corners. Triggers MemoryFadeSystem when reaching checkpoint waypoints on the route.
Inspector Fields:
| Field | Type | Description |
|---|---|---|
| route | WaypointRoute | Route to follow |
| kickr | KickrReader | Speed source |
| memoryFadeSystem | MemoryFadeSystem | Video trigger target |
| maxRealWorldSpeed | float | Kickr km/h that maps to max game speed |
| maxGameSpeed | float | Maximum Unity units per second |
| rotationSpeed | float | How fast bike turns toward waypoint |
| waypointThreshold | float | Distance to advance to next waypoint |
| maxTiltAngle | float | Maximum banking angle on corners |
WaypointRoute.cs
Holds all waypoints as child GameObjects. Draws green route lines in Scene view. Used by BikeRouteFollower, MiniMap, and ElevationSystem.
Inspector Fields:
| Field | Description |
|---|---|
| loop | If true, route loops back to start when complete |
Public Methods:
- GetWaypoints() — Returns all child transforms as waypoint array
- GetWaypoint(int index) — Returns waypoint at index with loop support
MemoryFadeSystem.cs
Plays muted video overlays on a RawImage. Triggered by proximity to MemoryCheckpoints or directly by BikeRouteFollower at waypoints. Supports fade, glitch effect, and PlayOnce mode.
Note: Videos must be H.264 MP4 in Assets/StreamingAssets/. HEVC and MOV formats are not supported on Mac.
Inspector Fields:
| Field | Type | Description |
|---|---|---|
| memoryOverlay | RawImage | Target display RawImage |
| checkpoints | MemoryCheckpoint[] | Proximity trigger array |
| maxOpacity | float | Maximum overlay opacity 0-1 |
| fadeSpeed | float | Opacity transition speed |
| useGlitch | bool | Enable VHS glitch flicker effect |
| minPower | float | Minimum power for opacity |
| powerForFullOpacity | float | Power for maximum opacity |
Public Methods:
- PlayOnce(string filename) — Plays a video once then fades out. Called by BikeRouteFollower.
MemoryCheckpoint.cs
Attached to scene objects or WaypointRoute child objects. Stores the video filename and trigger radius for the MemoryFadeSystem.
Inspector Fields:
| Field | Type | Description |
|---|---|---|
| checkpointName | string | Display name |
| triggerRadius | float | Proximity radius in Unity units |
| videoFileName | string | Filename in StreamingAssets e.g. ride1_h264.mp4 |
MiniMap.cs
Draws a real-time top-down mini-map onto a RawImage texture every frame. Shows the full route in grey, completed section in orange, memory checkpoint pins in teal, and a pulsing player dot with direction arrow.
Inspector Fields:
| Field | Type | Description |
|---|---|---|
| route | WaypointRoute | Route to draw |
| bikeTransform | Transform | Player position source |
| mapDisplay | RawImage | Target RawImage in Canvas |
| progressText | TMP_Text | Optional km and % progress text |
| mapSize | int | Texture resolution in pixels (default 300) |
ElevationSystem.cs
Reads waypoint Y positions to calculate gradient between current and next waypoint. Adjusts KICKR resistance and bike game speed based on uphill or downhill percentage. Shows gradient and elevation in the HUD.
Inspector Fields:
| Field | Type | Description |
|---|---|---|
| route | WaypointRoute | Route with elevation Y data |
| kickr | KickrReader | Resistance command target |
| bikeFollower | BikeRouteFollower | Current waypoint index source |
| maxGradientPercent | float | Gradient % that maps to max resistance |
| maxResistanceWatts | float | Watts sent to KICKR at steepest climb |
| minResistanceWatts | float | Watts sent on steepest descent |
| gradientText | TMP_Text | Gradient % display |
| elevationText | TMP_Text | Elevation metres display |
MusicPlayer.cs
Always-playing playlist that fades volume based on cycling speed. Volume fades to minimum when stopped and fades back to maximum when pedaling. Shows GTA-style song card with album art when tracks change.
Inspector Fields:
| Field | Type | Description |
|---|---|---|
| kickr | KickrReader | Speed source for volume |
| songs | SongData[] | Playlist with clip, art, title, artist |
| maxVolume | float | Volume when pedaling (default 0.8) |
| minVolume | float | Volume when stopped (default 0.1) |
| cardGroup | CanvasGroup | Song card panel |
| albumArt | RawImage | Album art display |
| cardShowDuration | float | How long card stays visible in seconds |
Public Methods:
- NextSong() — Skip to next track
- PreviousSong() — Go to previous track
- TogglePlayPause() — Pause or resume playback
LoadingScreen.cs
Full-screen loading screen while Bluetooth devices connect. Supports video background, custom logo and device icons. Auto-dismisses when both devices are ready.
Inspector Fields:
| Field | Type | Description |
|---|---|---|
| backgroundVideoFile | string | Looping video filename in StreamingAssets |
| backgroundSprite | Sprite | Fallback background image |
| titleSprite | Sprite | Logo PNG sprite |
| kickrSprite | Sprite | KICKR icon PNG sprite |
| hrmSprite | Sprite | HRM icon PNG sprite |
| titlePosition and titleSize | Vector2 | Logo position and size on screen |
| kickrPosition and kickrSize | Vector2 | KICKR icon position and size |
| hrmPosition and hrmSize | Vector2 | HRM icon position and size |
| minLoadTime | float | Minimum seconds to show loading screen |
| ridingTips | string[] | Tips shown while waiting for devices |
RideSummary.cs
Tracks ride statistics throughout the session and builds a complete summary card in code when the route completes. No manual Unity UI setup needed — all elements created programmatically at runtime.
Inspector Fields:
| Field | Type | Description |
|---|---|---|
| kickr | KickrReader | Stats data source |
| bikeFollower | BikeRouteFollower | Route completion detection |
| canvas | Canvas | Parent canvas for the card |
| logoSprite | Sprite | Optional logo at top of card |
| cardWidth and cardHeight | float | Card dimensions (default 1800×1240) |
| accentColor | Color | Accent color for bars and buttons |
Public Methods:
- ShowSummary() or EndRide() — Manually trigger the summary screen
- RestartRide() — Reset all stats and hide the summary
SETUP GUIDES
Adding a New Route
- Right-click Hierarchy → Create Empty → name it Route. Add WaypointRoute component.
- Select Route in the Hierarchy → Inspector → click START Placing Waypoints.
- Switch to the Scene tab. Hold Shift and click on the road surface to drop waypoints. A green line connects them live.
- Click STOP when done. Use Undo Last Waypoint to remove mistakes.
- Add BikeRouteFollower to Bicycle Standard and drag Route into the Route slot.
Adding Memory Video Checkpoints
Proximity Trigger — video fades in as you approach:
- Add MemoryCheckpoint component to any GameObject in the scene
- Set Video File Name to your H.264 MP4 filename
- Set Trigger Radius — how close the bike needs to be
- Add it to the Checkpoints array on MemoryFadeSystem
Waypoint Trigger — video plays once at a specific point:
- Add MemoryCheckpoint component to any WP_XX child of Route
- Set Video File Name to your H.264 MP4 filename
- Make sure BikeRouteFollower has MemoryFadeSystem assigned
Converting Videos for Unity
Unity on Mac requires H.264 MP4 with baseline profile and constant frame rate:
ffmpeg -i input.mp4 -c:v libx264 -pix_fmt yuv420p -profile:v baseline -level 3.0 -vf fps=30 -c:a aac output_h264.mp4
To rotate portrait phone videos to landscape:
ffmpeg -i input.mp4 -vf transpose=2 -c:v libx264 -pix_fmt yuv420p -c:a aac output_landscape.mp4
Setting Up Elevation
The elevation system reads the Y position of each waypoint to calculate gradient:
- Select WP_XX objects on hill sections in Scene view
- Raise their Y position to match the terrain height
- Positive gradient means uphill which gives higher resistance and slower speed
- Negative gradient means downhill which gives lower resistance and faster speed
- Adjust Max Resistance Watts and Min Resistance Watts in ElevationSystem Inspector
Adding Music
- Place MP3 or WAV files in Assets/Music/. Unity imports them as AudioClips automatically.
- Click MusicPlayer in Hierarchy. Set Songs array size. For each element drag in the AudioClip, album art PNG, and type the Title and Artist name.
- For album art: click the PNG in Project → Inspector → change Texture Type to Sprite (2D and UI) → Apply. Then drag into the Album Art slot.
TROUBLESHOOTING
KICKR Not Connecting
Most Bluetooth issues are permissions-related on Mac.
- Go to Mac System Settings → Privacy & Security → Bluetooth → add Unity to allowed apps
- Go to Edit → Project Settings → Player → Mac → Other Settings → enable Bluetooth
- Make sure KICKR is not connected to Zwift, Wahoo App or another device
- Unplug KICKR power for 10 seconds then reconnect
- Check Unity Console for BLE ERROR messages
Video Not Playing
- File must be in Assets/StreamingAssets/ folder
- Must be H.264 encoded — HEVC and QuickTime MOV will not work
- Check the file format in Terminal by running: file yourfile.mp4
- Re-encode with ffmpeg using the command from the Setup Guides page
- Filename in Inspector must match exactly including the extension
- Check Console for AVFoundationVideoMedia error messages
Mini-Map Not Showing
- RawImage must be assigned to Map Display on MiniMap component
- Route and Bike Transform must both be assigned
- Waypoints must exist as children of the Route object
- Make sure the RawImage is visible and not hidden behind other UI elements
Music Not Playing
- AudioClips must be assigned in the Songs array on MusicPlayer
- Music fades to minVolume when not pedaling — this is normal behavior
- Set minVolume to 0.5 in Inspector to hear it clearly at rest
- Check that Unity audio is not muted (speaker icon in Game view toolbar)
Loading Screen Not Dismissing
- Waits for both KICKR and HRM to connect before dismissing
- If HRM is not available, dismisses after 10 seconds with KICKR only
- Set minLoadTime to 0 in Inspector to dismiss immediately for testing
- Check Console for Bluetooth permission error messages
Route Not Moving
- Check BikeRouteFollower has Route and KickrReader assigned in Inspector
- Make sure waypoints exist as children of the Route object
- Check debugKickrSpeed on BikeRouteFollower — should be above 0 when pedaling
- Set minGameSpeed above 0 to test movement without hardware connected
REFERENCES
Tools and Engines
| Tool | Version | Link |
|---|---|---|
| Unity Engine | 2022.3 LTS | unity.com |
| C# / .NET | Standard 2.1 | docs.microsoft.com |
| ffmpeg | Latest | ffmpeg.org |
| Homebrew | Latest | brew.sh |
Unity Packages and Plugins
| Package | Source | Usage |
|---|---|---|
| BluetoothLEHardwareInterface | Shatalmic / Asset Store | BLE communication with KICKR and HRM |
| Simple Bicycle Physics | Unity Asset Store | Bike physics, animation and IK |
| TextMeshPro | Unity built-in | All UI text rendering |
| Unity VideoPlayer | Unity built-in | Memory video overlay playback |
| Unity UI uGUI | Unity built-in | Canvas, Image, Button, Slider, RawImage |
| UnityEngine.Video | Unity built-in | VideoPlayer and RenderTexture |
| UnityEngine.UI | Unity built-in | All UI components |
| ArduinoHM10 Shatalmic | Asset Store | Bluetooth bridge scripts |
Hardware
| Device | Manufacturer | Link |
|---|---|---|
| KICKR Smart Trainer | Wahoo Fitness | wahoofitness.com |
| COROS Heart Rate Monitor | COROS | coros.com |
Bluetooth Standards
| Service | UUID | Standard |
|---|---|---|
| Fitness Machine Service | 0x1826 | Bluetooth SIG |
| Indoor Bike Data | 0x2AD2 | Bluetooth SIG |
| Fitness Machine Control Point | 0x2AD9 | Bluetooth SIG |
| Heart Rate Service | 0x180D | Bluetooth SIG |
| Heart Rate Measurement | 0x2A37 | Bluetooth SIG |
| Cycling Power Service | 0x1818 | Bluetooth SIG |
Built by Ervin Zhuang • NORRA Cycling Experience • Unity 2022.3 • April 2026


Leave a comment