Skip to main content

What Problem Does This Solve?

Following best practices helps you:
  • Avoid common integration mistakes
  • Optimize recording performance
  • Handle errors gracefully
  • Write maintainable code
  • Deliver smooth user experience
This page collects lessons learned from hundreds of LCK integrations.

When to Use This

Read this when:
  • First-time LCK integration
  • Debugging performance issues
  • Building custom recording UI
  • Optimizing for Quest devices
  • Code review / refactoring

Recording

Quality Guidelines

QualityResolutionBitrateFPSUse Case
SD1280×7202-4 Mbps30Quest 2, performance mode
HD1920×10804-8 Mbps30Standard quality (recommended)
2K2560×14408-12 Mbps30Quest 3/Pro, high quality
4K3840×216012-20 Mbps30PCVR only, max quality
Quest recommendations:
  • Quest 2: Use HD (1080p) @ 30fps for best balance
  • Quest 3/Pro: Can handle 2K if game performance allows
  • Avoid 4K on Quest devices (thermal/performance)
File size estimates:
  • HD @ 30fps: ~2 GB per hour
  • 2K @ 30fps: ~4 GB per hour
  • 4K @ 30fps: ~8 GB per hour

Use Async Methods

Do this:
Service->StartRecordingAsync(
    FOnLCKRecorderBoolResult::CreateLambda([this](bool bSuccess)
    {
        if (bSuccess)
        {
            ShowRecordingIndicator();
            PlayRecordingSound();
        }
        else
        {
            ShowError(TEXT("Failed to start recording"));
        }
    })
);
Don’t do this:
// No error feedback, blocks game thread
bool bSuccess = Service->StartRecording();
Why async is better:
  • Non-blocking—doesn’t freeze game
  • Detailed error callbacks
  • Progress tracking for save operations
  • Better user experience

Subscribe to Recording Events

Do this:
void ARecordingUI::BeginPlay()
{
    Super::BeginPlay();
    
    // Subscribe to events
    Service->OnRecordingSaveFinished.AddDynamic(this, &ARecordingUI::OnSaveFinished);
    Service->OnRecordingError.AddDynamic(this, &ARecordingUI::OnError);
    Service->OnRecordingSaveProgress.AddDynamic(this, &ARecordingUI::OnProgress);
    
    // Get state changes via DataModel
    DataModel->OnRecordingStateChanged.AddDynamic(this, &ARecordingUI::OnStateChanged);
}

UFUNCTION()
void ARecordingUI::OnSaveFinished(bool bSuccess)
{
    if (bSuccess)
        ShowNotification(TEXT("Recording saved!"));
}
Don’t do this:
// Polling state every frame = BAD
void Tick(float DeltaTime)
{
    if (Service->IsRecording() != bWasRecording)
    {
        bWasRecording = Service->IsRecording();
        UpdateUI();
    }
}
Why events are better:
  • React immediately to state changes
  • No performance cost of polling
  • Cleaner code
  • More responsive UI

Validate Before Recording

bool ARecorder::CanStartRecording()
{
    // 1. Check tracking ID
    ULCKDeveloperSettings* Settings = ULCKDeveloperSettings::Get();
    if (!Settings->IsTrackingIdValid())
    {
        ShowError(TEXT("Recording not configured"));
        return false;
    }
    
    // 2. Check if already recording
    if (Service->IsRecording())
    {
        UE_LOG(LogLCK, Warning, TEXT("Already recording"));
        return false;
    }
    
    // 3. Check storage space
    int64 FreeSpace = FPlatformMisc::GetDiskFreeSpace(FPaths::ProjectSavedDir());
    int64 RequiredSpace = 500 * 1024 * 1024; // 500 MB
    if (FreeSpace < RequiredSpace)
    {
        ShowError(FString::Printf(
            TEXT("Low storage: %d MB free. Need 500 MB minimum."),
            FreeSpace / (1024 * 1024)
        ));
        return false;
    }
    
    // 4. Validate audio config (warnings, not blocking)
    FLCKAudioConfigValidation AudioValidation = Settings->ValidateAudioConfig();
    for (const FString& Warning : AudioValidation.Warnings)
    {
        UE_LOG(LogLCK, Warning, TEXT("Audio: %s"), *Warning);
    }
    
    return true;
}

void ARecorder::StartRecording()
{
    if (!CanStartRecording())
        return;
    
    Service->StartRecordingAsync(/* ... */);
}

Audio

Audio Source Priority

When multiple audio plugins are enabled, LCK uses priority order:
  1. LCKFMOD (highest priority)
  2. LCKWwise
  3. LCKUnrealAudio (lowest priority, always available)
Only ONE game audio source is active at a time. Microphone and voice chat can run alongside game audio.
Check which is active:
ELCKGameAudioType ActiveAudio = Settings->GetActiveGameAudioType();

switch (ActiveAudio)
{
    case ELCKGameAudioType::FMOD:
        UE_LOG(LogLCK, Log, TEXT("Using FMOD for game audio"));
        break;
    case ELCKGameAudioType::Wwise:
        UE_LOG(LogLCK, Log, TEXT("Using Wwise for game audio"));
        break;
    case ELCKGameAudioType::UnrealAudio:
        UE_LOG(LogLCK, Log, TEXT("Using Unreal Audio"));
        break;
}

Match Sample Rates

Do this:
// Get Unreal Audio sample rate
int32 SampleRate = ULCKUnrealAudioBPL::GetUnrealAudioSamplerate();

// Configure encoder with matching rate
FLCKRecorderParams Params;
Params.Width = 1920;
Params.Height = 1080;
Params.Framerate = 30;
Params.Samplerate = SampleRate;  // Match!

Recorder->SetupRecorder(Params, CaptureComponent);
Don’t do this:
// Hardcoded sample rate = audio distortion/sync issues
Params.Samplerate = 48000; // May not match actual audio

Thread-Safe Audio Callbacks

Audio callbacks may come from different threads. If you need to access game thread objects (UObject properties, UI), use AsyncTask.
Do this:
AudioSource->OnAudioDataDelegate.BindLambda([this](
    TArrayView<const float> PCM,
    int32 Channels,
    ELCKAudioChannel SourceChannel)
{
    // Calculate volume on audio thread (OK)
    float Volume = CalculateRMS(PCM);
    
    // Update UI on game thread
    AsyncTask(ENamedThreads::GameThread, [this, Volume]()
    {
        VolumeIndicator->SetPercent(Volume);
    });
});
Don’t do this:
AudioSource->OnAudioDataDelegate.BindLambda([this](auto PCM, auto Channels, auto Source)
{
    // CRASH: Accessing UObject from non-game thread
    VolumeIndicator->SetPercent(CalculateRMS(PCM));
});

Audio Delegate is Single, Not Multicast

OnAudioDataDelegate is a SINGLE delegate. Use BindLambda(), NOT AddLambda().
Do this:
// BindLambda replaces any existing binding
AudioSource->OnAudioDataDelegate.BindLambda([](auto PCM, auto Channels, auto Source)
{
    // Process audio
});
Don’t do this:
// AddLambda doesn't exist on single delegates
AudioSource->OnAudioDataDelegate.AddLambda([](auto PCM, auto Channels, auto Source)
{
    // Compilation error
});

UI

Don’t Add Your Own Button Cooldown

LCK buttons include automatic 0.25s cooldown. Do this:
// Just bind the event, cooldown is automatic
RecordButton->OnTapStarted.AddDynamic(this, &AMyActor::OnRecordPressed);
Don’t do this:
// Redundant cooldown logic
void OnRecordPressed()
{
    if (FPlatformTime::Seconds() - LastPressTime < 0.25f)
        return; // Unnecessary!
    
    LastPressTime = FPlatformTime::Seconds();
    ToggleRecording();
}

Use Showable Groups for Batch Operations

Do this:
// Group related UI elements
ULCKShowablesGroup* SettingsGroup = NewObject<ULCKShowablesGroup>(this);
SettingsGroup->Add(FOVButton);
SettingsGroup->Add(DistanceButton);
SettingsGroup->Add(SmoothnessButton);

// Batch show/hide
void ShowSettings()
{
    SettingsGroup->Show();
}

void HideSettings()
{
    SettingsGroup->Hide();
}
Don’t do this:
// Manually show/hide each element
void ShowSettings()
{
    FOVButton->SetVisibility(true);
    DistanceButton->SetVisibility(true);
    SmoothnessButton->SetVisibility(true);
}

Performance

Optimize Scene Capture Component

void ARecorder::SetupCaptureComponent(USceneCaptureComponent2D* Capture)
{
    // Disable expensive capture options
    Capture->CaptureSource = ESceneCaptureSource::SCS_FinalColorLDR;
    Capture->bCaptureEveryFrame = false;    // Manual capture
    Capture->bCaptureOnMovement = false;    // Manual capture
    Capture->bAlwaysPersistRenderingState = true;
    
    // Disable post-processing for performance
    Capture->PostProcessSettings.bOverride_AmbientOcclusionIntensity = true;
    Capture->PostProcessSettings.AmbientOcclusionIntensity = 0.0f;
    
    // Disable expensive features
    Capture->ShowFlags.SetTemporalAA(false);
    Capture->ShowFlags.SetMotionBlur(false);
}

Unregister Capture Components

Do this:
void ARecorder::BeginDestroy()
{
    Super::BeginDestroy();
    
    if (Service)
    {
        Service->StopRecording();
        Service->UnregisterCaptureComponent(TEXT("MainCapture"));
    }
}
Don’t do this:
// Memory leak - capture component never cleaned up
void ARecorder::BeginDestroy()
{
    Super::BeginDestroy();
    Service->StopRecording();
}

Monitor Frame Times During Recording

void APerformanceMonitor::Tick(float DeltaTime)
{
    if (!Service || !Service->IsRecording())
        return;
    
    float FrameTimeMs = DeltaTime * 1000.0f;
    
    // Track frame time history
    FrameTimeHistory.Add(FrameTimeMs);
    if (FrameTimeHistory.Num() > 60) // 2 seconds at 30fps
    {
        FrameTimeHistory.RemoveAt(0);
    }
    
    // Calculate average
    float AvgFrameTime = 0.0f;
    for (float Time : FrameTimeHistory)
    {
        AvgFrameTime += Time;
    }
    AvgFrameTime /= FrameTimeHistory.Num();
    
    // Warn if frame time is too high
    float TargetFrameTime = 1000.0f / 30.0f; // 33.3ms for 30fps
    if (AvgFrameTime > TargetFrameTime * 1.2f) // 20% over target
    {
        UE_LOG(LogLCK, Warning, TEXT("Recording impacting performance: %.1fms avg"), 
            AvgFrameTime);
        SuggestLowerQuality();
    }
}

Common Pitfalls

1. Not Handling Recording State Feedback

Problem: No feedback when recording starts/stops/fails Solution: Subscribe to delegates
Service->OnRecordingSaveFinished.AddDynamic(this, &AMyUI::OnSaveFinished);
Service->OnRecordingError.AddDynamic(this, &AMyUI::OnError);
DataModel->OnRecordingStateChanged.AddDynamic(this, &AMyUI::OnStateChanged);

2. Mismatched Resolutions

Problem: Render target doesn’t match recording resolution → distortion Solution: Match exactly
// Render target
RenderTarget->ResX = 1920;
RenderTarget->ResY = 1080;

// Recording params
FLCKRecorderParams Params;
Params.Width = 1920;  // Must match RenderTarget->ResX
Params.Height = 1080; // Must match RenderTarget->ResY

3. Forgetting to Unregister Capture Component

Problem: Memory leak from orphaned capture components Solution: Always unregister on cleanup
void ARecorder::BeginDestroy()
{
    Super::BeginDestroy();
    
    if (Service)
    {
        Service->UnregisterCaptureComponent(CaptureComponentName);
    }
}

4. Sample Rate Mismatch

Problem: Audio distortion or A/V sync issues Solution: Query and match sample rates
int32 SampleRate = ULCKUnrealAudioBPL::GetUnrealAudioSamplerate();
Params.Samplerate = SampleRate;

5. Using AddLambda for Audio Delegate

Problem: OnAudioDataDelegate is single, not multicast Solution: Use BindLambda() instead
// Correct
AudioSource->OnAudioDataDelegate.BindLambda([](auto PCM, auto Channels, auto Source) {
    // ...
});

// Wrong - compilation error
AudioSource->OnAudioDataDelegate.AddLambda([](auto PCM, auto Channels, auto Source) {
    // ...
});

Platform Checklist

Android (Quest)

Vulkan enabled in Project Settings → Android
RECORD_AUDIO permission in AndroidManifest.xml
WRITE_EXTERNAL_STORAGE permission (API < 29)
LCKOboe plugin enabled for low-latency mic
LCKVulkan loads at EarliestPossible (don’t change!)
AndroidManifest.xml:
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" 
                 android:maxSdkVersion="28" />

Windows (PCVR)

Media Foundation available (Windows 10+)
DirectX 11 compatible GPU
H.264 hardware encoding support

Debugging

Enable Verbose Logging

; DefaultEngine.ini
[Core.Log]
LogLCK=VeryVerbose
LogLCKEncoding=VeryVerbose
LogLCKAudio=VeryVerbose
LogLCKUI=Verbose
LogLCKTablet=Verbose
What you’ll see:
LogLCK: Recording started
LogLCKEncoding: Encoder initialized: 1920x1080 @ 30fps, 8 Mbps
LogLCKAudio: Audio source registered: UnrealAudio
LogLCKEncoding: Frame 0 encoded (8.2ms)
LogLCKEncoding: Frame 30 encoded (7.9ms)
LogLCK: Recording stopped
LogLCKEncoding: Finalizing video file...
LogLCK: Recording saved: /Game/Movies/recording_001.mp4

Common Log Messages

Log MessageMeaningAction
Recording startedRecording began successfully-
Encoder initializedPlatform encoder ready-
Audio source registeredAudio capture active-
Invalid Tracking IDTracking ID missing/invalidAdd ID from dashboard
Encoder not availablePlatform encoder failedCheck platform requirements
Permission deniedMissing permissions (Android)Request permissions

Quick Reference

Start recording:
Service->StartRecordingAsync(FOnLCKRecorderBoolResult::CreateLambda([](bool bSuccess) {
    // Handle result
}));
Stop recording:
Service->StopRecordingAsync(
    FOnLCKRecorderBoolResult::CreateLambda([](bool bSuccess) { /* Done */ }),
    FOnLCKRecorderProgress::CreateLambda([](float Progress) { /* 0.0-1.0 */ })
);
Check state:
bool bRecording = Service->IsRecording();
float Duration = Service->GetRecordingDuration();
Subscribe to events:
Service->OnRecordingSaveFinished.AddDynamic(this, &AMyActor::OnSaveFinished);
Service->OnRecordingError.AddDynamic(this, &AMyActor::OnError);
DataModel->OnRecordingStateChanged.AddDynamic(this, &AMyActor::OnStateChanged);

Key Takeaways

Use async methods for better error handling
Subscribe to events instead of polling state
Validate before recording (tracking ID, storage, state)
Match sample rates to avoid audio issues
Audio callbacks on any thread — use AsyncTask for UI updates
Single delegate for audio — use BindLambda, not AddLambda
Unregister captures to prevent memory leaks
HD @ 30fps for Quest 2 — higher quality impacts performance