NORRA Cycling Experience Documentation

Written by:

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

ComponentTechnologyVersion
Game EngineUnity2022.3 LTS
LanguageC#.NET Standard 2.1
BluetoothBluetoothLEHardwareInterfaceShatalmic
UIUnity uGUI + TextMeshProBuilt-in
VideoUnity VideoPlayer + RenderTextureBuilt-in
Bike PhysicsSimple Bicycle PhysicsAsset Store
PlatformmacOSMac 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

  1. Open the Project — Open Unity Hub, click Add, navigate to the NORRA folder. Select Unity 2022.3 as the editor version.
  2. 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.
  3. 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.
  4. Add Music Files — Place MP3 or WAV files in Assets/Music/. Drag them into the MusicPlayer component Songs array in the Inspector.
  5. 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.
  6. 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:

PropertyTypeDescription
PowerfloatCurrent power output in watts
CadencefloatCurrent cadence in RPM
SpeedfloatCurrent speed in km/h
HeartRateintCurrent heart rate in BPM
ConnectedboolTrue when KICKR is connected
HRMConnectedboolTrue when HRM is connected
StatusstringCurrent KICKR connection status message
HRMStatusstringCurrent HRM connection status message

Inspector Fields:

FieldTypeDescription
connectionStatusTMP_TextShows KICKR status in UI
hrmTextTMP_TextShows HR and zone in UI
hrmStatusTextTMP_TextShows HRM connection status
z1Max — z4MaxintBPM thresholds for HR zones Z1-Z5

Public Methods:

  • SetTargetPower(int watts) — Sends ERG resistance command to KICKR

BLE UUIDs:

DeviceService UUIDCharacteristic UUID
KICKR Data18262AD2
KICKR ERG Control18262AD9
Heart Rate Monitor180D2A37

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:

FieldTypeDescription
kickrKickrReaderData source
bikeDataTMP_TextMain HUD text
workoutStatusTMP_TextInterval info display
powerSliderSliderManual power target
intervalsList of WorkoutIntervalERG 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:

FieldTypeDescription
kickrKickrReaderData source
powerTextTMP_TextPower display
cadenceTextTMP_TextCadence display
speedTextTMP_TextSpeed display
zoneTextTMP_TextZone name display
powerFontSizefloatPower 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:

FieldTypeDescription
routeWaypointRouteRoute to follow
kickrKickrReaderSpeed source
memoryFadeSystemMemoryFadeSystemVideo trigger target
maxRealWorldSpeedfloatKickr km/h that maps to max game speed
maxGameSpeedfloatMaximum Unity units per second
rotationSpeedfloatHow fast bike turns toward waypoint
waypointThresholdfloatDistance to advance to next waypoint
maxTiltAnglefloatMaximum 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:

FieldDescription
loopIf 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:

FieldTypeDescription
memoryOverlayRawImageTarget display RawImage
checkpointsMemoryCheckpoint[]Proximity trigger array
maxOpacityfloatMaximum overlay opacity 0-1
fadeSpeedfloatOpacity transition speed
useGlitchboolEnable VHS glitch flicker effect
minPowerfloatMinimum power for opacity
powerForFullOpacityfloatPower 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:

FieldTypeDescription
checkpointNamestringDisplay name
triggerRadiusfloatProximity radius in Unity units
videoFileNamestringFilename 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:

FieldTypeDescription
routeWaypointRouteRoute to draw
bikeTransformTransformPlayer position source
mapDisplayRawImageTarget RawImage in Canvas
progressTextTMP_TextOptional km and % progress text
mapSizeintTexture 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:

FieldTypeDescription
routeWaypointRouteRoute with elevation Y data
kickrKickrReaderResistance command target
bikeFollowerBikeRouteFollowerCurrent waypoint index source
maxGradientPercentfloatGradient % that maps to max resistance
maxResistanceWattsfloatWatts sent to KICKR at steepest climb
minResistanceWattsfloatWatts sent on steepest descent
gradientTextTMP_TextGradient % display
elevationTextTMP_TextElevation 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:

FieldTypeDescription
kickrKickrReaderSpeed source for volume
songsSongData[]Playlist with clip, art, title, artist
maxVolumefloatVolume when pedaling (default 0.8)
minVolumefloatVolume when stopped (default 0.1)
cardGroupCanvasGroupSong card panel
albumArtRawImageAlbum art display
cardShowDurationfloatHow 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:

FieldTypeDescription
backgroundVideoFilestringLooping video filename in StreamingAssets
backgroundSpriteSpriteFallback background image
titleSpriteSpriteLogo PNG sprite
kickrSpriteSpriteKICKR icon PNG sprite
hrmSpriteSpriteHRM icon PNG sprite
titlePosition and titleSizeVector2Logo position and size on screen
kickrPosition and kickrSizeVector2KICKR icon position and size
hrmPosition and hrmSizeVector2HRM icon position and size
minLoadTimefloatMinimum seconds to show loading screen
ridingTipsstring[]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:

FieldTypeDescription
kickrKickrReaderStats data source
bikeFollowerBikeRouteFollowerRoute completion detection
canvasCanvasParent canvas for the card
logoSpriteSpriteOptional logo at top of card
cardWidth and cardHeightfloatCard dimensions (default 1800×1240)
accentColorColorAccent 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

  1. Right-click Hierarchy → Create Empty → name it Route. Add WaypointRoute component.
  2. Select Route in the Hierarchy → Inspector → click START Placing Waypoints.
  3. Switch to the Scene tab. Hold Shift and click on the road surface to drop waypoints. A green line connects them live.
  4. Click STOP when done. Use Undo Last Waypoint to remove mistakes.
  5. 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

  1. Place MP3 or WAV files in Assets/Music/. Unity imports them as AudioClips automatically.
  2. 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.
  3. 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

ToolVersionLink
Unity Engine2022.3 LTSunity.com
C# / .NETStandard 2.1docs.microsoft.com
ffmpegLatestffmpeg.org
HomebrewLatestbrew.sh

Unity Packages and Plugins

PackageSourceUsage
BluetoothLEHardwareInterfaceShatalmic / Asset StoreBLE communication with KICKR and HRM
Simple Bicycle PhysicsUnity Asset StoreBike physics, animation and IK
TextMeshProUnity built-inAll UI text rendering
Unity VideoPlayerUnity built-inMemory video overlay playback
Unity UI uGUIUnity built-inCanvas, Image, Button, Slider, RawImage
UnityEngine.VideoUnity built-inVideoPlayer and RenderTexture
UnityEngine.UIUnity built-inAll UI components
ArduinoHM10 ShatalmicAsset StoreBluetooth bridge scripts

Hardware

DeviceManufacturerLink
KICKR Smart TrainerWahoo Fitnesswahoofitness.com
COROS Heart Rate Monitor COROScoros.com

Bluetooth Standards

ServiceUUIDStandard
Fitness Machine Service0x1826Bluetooth SIG
Indoor Bike Data0x2AD2Bluetooth SIG
Fitness Machine Control Point0x2AD9Bluetooth SIG
Heart Rate Service0x180DBluetooth SIG
Heart Rate Measurement0x2A37Bluetooth SIG
Cycling Power Service0x1818Bluetooth SIG

Built by Ervin Zhuang • NORRA Cycling Experience • Unity 2022.3 • April 2026

Leave a comment

Latest Articles

Previous: