How Lighthouse’s Custom Controller Mapping System Works: A Deep Dive into the Mapper Architecture

Lighthouse implements a user-editable controller mapping layer through the Mapper namespace, which wraps SDL's gamepad API with a state-machine-driven binding system that persists mappings in standard gamecontrollerdb.txt format.

The HarbourMasters/Lighthouse project provides flexible controller support for classic game engines by letting players create custom mappings without editing configuration files by hand. This article examines the Mapper namespace implementation in src/port/Controller/Mapper.h and Mapper.cpp, tracing how raw SDL joystick input becomes persistent, reloadable controller configurations.

Core Architecture: The Mapper Namespace

The custom controller mapping system centers on three collaborating components:

  • MappingSession – A state machine that guides users through a predefined binding sequence
  • MapperWindow – The ImGui-based UI that displays prompts and visual feedback
  • Persistence layer – Functions that read and write gamecontrollerdb.txt-compatible strings

This separation allows the binding logic to remain testable and UI-agnostic while providing an intuitive "click-to-bind" experience for players.

Detecting Raw SDL Input

The mapping process begins when MapperWindow opens an SDL_Joystick* handle for the selected device. In MapperWindow::PollDeviceForSession (Mapper.cpp lines 151-166), the window polls the joystick each frame and forwards raw events to the active session:

void MapperWindow::PollDeviceForSession() {
    if (!mSession.IsActive()) return;
    
    // Poll buttons
    for (int i = 0; i < SDL_JoystickNumButtons(mJoystick); i++) {
        if (SDL_JoystickGetButton(mJoystick, i)) {
            mSession.ProcessButtonDown(i);
        }
    }
    
    // Poll axes and hats similarly...
}

This polling approach ensures compatibility with any SDL-supported controller, regardless of whether SDL recognizes it natively.

The MappingSession State Machine

MappingSession manages the binding sequence through an ordered vector of target bindings (mOrder) and tracks progress via mCurrentStep. As defined in Mapper.h lines 64-84, the session advances only when the user provides the explicitly requested input type.

Axis Binding Thresholds

For analog stick bindings, the session implements a two-threshold system to prevent accidental triggers:

Threshold Value Purpose
AXIS_COMMIT_DISTANCE 16000 Binding is locked in when stick moves this far from center
AXIS_RETURN_DISTANCE 10000 Session can advance when stick returns within this range

These constants (defined in Mapper.h lines 66-68) ensure the user deliberately moves the stick rather than brushing it accidentally. The state machine requires the stick to exceed the commit distance, then return near center, before marking the binding complete.

A typical axis capture sequence works like this:

// Simplified from MappingSession implementation
void ProcessAxisMotion(int axis, Sint16 value) {
    if (mCurrentStepWantsAxis()) {
        if (abs(value) > AXIS_COMMIT_DISTANCE && !mCommitReceived) {
            // Record which direction (positive/negative) and wait for return
            mPendingBinding.axis = axis;
            mPendingBinding.axisPositive = (value > 0);
            mCommitReceived = true;
        }
        else if (mCommitReceived && abs(value) < AXIS_RETURN_DISTANCE) {
            // Stick returned to neutral, binding is complete
            mBindings[mCurrentStep] = mPendingBinding;
            Advance();
        }
    }
}

Generating Standard-Format Mapping Strings

Once all bindings are captured, GenerateMappingString (declared in Mapper.h lines 11-13) produces a gamecontrollerdb.txt-compatible string. This format—widely used across SDL-based games—ensures portability and tool compatibility:


030000005e0400008e02000011010000,Xbox Controller,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux,

The mapping includes:

  • GUID – Unique hardware identifier derived from SDL_JoystickGetGUID
  • Name – User-editable display name
  • Button mappings – Format logicalButton:physicalInput (e.g., a:b0 means "logical A button maps to physical button 0")
  • Axis mappings – Format axisName:axisIndex with sign for direction (e.g., leftx:a0)
  • Hat mappings – Format dpadDirection:h0.bitmask (e.g., dpup:h0.1)

Persistence and Runtime Loading

User mappings survive between sessions through two key functions (Mapper.h lines 19-22):

Function Purpose
SaveUserMapping Appends a new mapping string to usergamepadmappings.txt
LoadUserMappings Reads the file at startup and registers each entry via SDL_GameControllerAddMapping

The loading sequence occurs early in initialization, before any game code reads controller state:

// Early in main() or equivalent init
void InitializeControllers() {
    SDL_Init(SDL_INIT_GAMECONTROLLER);
    Mapper::LoadUserMappings();  // Registers all user overrides
    
    // SDL now uses custom mappings when opening controllers
    SDL_GameController* pad = SDL_GameControllerOpen(0);
}

Because SDL's mapping system is global, the rest of the codebase needs no awareness of custom mappings. Functions like controller_copyFaceButtons in src/core1/pfsmanager.c (lines 75-82) read standardized SDL controller state that already reflects the user's configuration.

UI Integration: MapperWindow

MapperWindow (defined in Mapper.h lines 29-71) inherits from Ship::GuiWindow to provide the binding interface. Its responsibilities include:

  1. Device selection – Lists available SDL_Joystick devices
  2. Visual controller diagram – Renders a gamepad with highlighted prompts
  3. Step-by-step guidance – Displays the current target binding from mOrder
  4. Live feedback – Animates when the session detects valid input

The window maintains loose coupling with MappingSession through an explicit interface:

// Starting a new mapping session
void MapperWindow::StartMapping() {
    std::vector<int32_t> order = {
        // Analog sticks first (requires commit/return sequence)
        Mapper::AXIS_LEFTX_NEGATIVE, Mapper::AXIS_LEFTX_POSITIVE,
        Mapper::AXIS_LEFTY_NEGATIVE, Mapper::AXIS_LEFTY_POSITIVE,
        Mapper::AXIS_RIGHTX_NEGATIVE, Mapper::AXIS_RIGHTX_POSITIVE,
        Mapper::AXIS_RIGHTY_NEGATIVE, Mapper::AXIS_RIGHTY_POSITIVE,
        // Then face buttons
        SDL_CONTROLLER_BUTTON_A, SDL_CONTROLLER_BUTTON_B,
        SDL_CONTROLLER_BUTTON_X, SDL_CONTROLLER_BUTTON_Y,
        // D-pad, shoulders, triggers, stick clicks, menu buttons...
    };
    
    mSession.Start(mJoystick, order);
    mState = MappingState::InProgress;
}

End-to-End Flow: From Physical Input to Game Action

The complete custom controller mapping lifecycle in Lighthouse follows these stages:

  1. Initialization – LoadUserMappings() parses usergamepadmappings.txt and registers entries with SDL
  2. User activation – Player opens the controller mapping UI and selects a device
  3. Binding sequence – MappingSession steps through mOrder, applying axis thresholds and debouncing
  4. String generation – GenerateMappingString() produces the canonical format
  5. Persistence – SaveUserMapping() appends to usergamepadmappings.txt
  6. Runtime consumption – Game code reads SDL controller state, unaware of mapping origins

This design isolates mapping complexity in the Mapper namespace while ensuring the game receives consistent, logical button names regardless of physical hardware.

Key Files and Their Roles

File Contribution to Custom Controller Mapping
src/port/Controller/Mapper.h Declares MappingSession, binding structures, GenerateMappingString(), and MapperWindow
src/port/Controller/Mapper.cpp Implements state machine logic, file I/O, and UI polling
src/port/Controller/ControlSchemes.h/cpp Applies input shaping (e.g., right-stick-to-C-buttons) on top of mapped input
src/core1/pfsmanager.c Consumes final SDL controller state via controller_copyFaceButtons and related helpers
usergamepadmappings.txt (runtime) Persistent storage in gamecontrollerdb.txt format

Summary

  • The Mapper namespace in src/port/Controller/Mapper.h and Mapper.cpp provides Lighthouse's custom controller mapping capabilities
  • MappingSession implements a state machine with AXIS_COMMIT_DISTANCE (16000) and AXIS_RETURN_DISTANCE (10000) thresholds for reliable analog capture
  • Standard gamecontrollerdb.txt format ensures tool compatibility and portable mapping strings
  • LoadUserMappings() and SaveUserMapping() handle persistence without requiring manual file editing
  • SDL's global mapping registry allows higher-level code to remain agnostic about custom configurations

Frequently Asked Questions

How does Lighthouse prevent accidental axis bindings during mapping?

The MappingSession state machine requires analog sticks to exceed AXIS_COMMIT_DISTANCE (16000) to register a binding, then return within AXIS_RETURN_DISTANCE (10000) before advancing. This two-threshold system ensures deliberate stick movement rather than incidental contact.

Can I edit Lighthouse controller mappings with external tools?

Yes. Because GenerateMappingString() produces standard gamecontrollerdb.txt format, any compatible editor—including Steam's Big Picture controller configurator or standalone tools—can read and modify usergamepadmappings.txt. The format uses comma-separated key:value pairs like a:b0,leftx:a0.

Where are custom mappings stored on disk?

User mappings append to usergamepadmappings.txt in the application's configuration directory via SaveUserMapping(). The file is parsed at startup by LoadUserMappings() and registered with SDL through SDL_GameControllerAddMapping().

What happens if I map a controller that SDL already recognizes?

Lighthouse user mappings take precedence. When LoadUserMappings() calls SDL_GameControllerAddMapping(), SDL replaces any existing entry with the same GUID. The game uses the user's custom mapping without requiring code changes in pfsmanager.c or other consumers.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →