CFXControl Flex Wiki
English中文

Setup & lifecycle

From the Gradle dependency to your first working callback: how to obtain the API, how Control Flex discovers your plugin, when each of the six callbacks runs, and which layer the JSON you install lands in — followed by the multi-loader project layout and a checklist for the day you release.

Section: Overview · Setup & lifecycle · Interfaces · Implementations · Who uses the API

Wiring in control-flex-api

// build.gradle
repositories { maven { url 'https://jitpack.io' } }

dependencies {
    compileOnly 'com.github.ControlFlexMC:control-flex-api:0.9.2'
    compileOnly 'org.jetbrains:annotations:24.0.1'
}
# mods.toml (Forge / NeoForge)
[[dependencies.your_mod_id]]
    modId = "controlflex"
    mandatory = true
    versionRange = "[0.8.8,)"
    ordering = "AFTER"
    side = "CLIENT"

# fabric.mod.json (Fabric)
{ "depends": { "controlflex": ">=0.8.8" } }

The first line is always an availability check — Control Flex may not be installed, and SDL3 may have failed to initialize. Every provider can be null.

if (!ControlFlexApi.isAvailable()) {
    return;   // Control Flex missing or not initialized: skip every controller path
}

Plugin skeleton: IControlFlexPlugin

Callback When it runs / what it is for
getModId() Plugin identifier, used for logging and de-duplication.
onInstallCompatConfigs() Called before ready; use installer.install() to install compat JSON into compat/mods/.
onInstallGuideAssets() Same idea, into guides/mods/.
onControlFlexReady() The single initialization callback; every provider is ready at this point.
onControllerConnectionChanged() Fires when a controller connects or disconnects.
cameraLookConsumer() Return an ICameraLookConsumer to take over each frame's look delta (0.8.8+, see camera takeover); null means no takeover.
requireApiVersion() Default method that checks the minimum API version and returns false when unmet.
package com.example.mymod;

import com.ifels.controlflex.api.*;

public class MyPlugin implements IControlFlexPlugin {

    @Override
    public String getModId() { return "my_bridge_mod"; }

    @Override
    public void onInstallCompatConfigs(ICompatAssetInstaller installer) {
        installer.install("/assets/my_bridge_mod/compat/targetmod.json", "targetmod.json");
    }

    @Override
    public void onControlFlexReady() {
        if (!requireApiVersion("0.8.8")) return;
        if (!ControlFlexApi.isAvailable()) return;

        ControlFlexApi.getPlayerStateRegistry().setState("mymod:initialized", true);
    }
}

Registration goes through an SPI services file; Control Flex discovers plugins automatically:

src/main/resources/META-INF/services/com.ifels.controlflex.api.IControlFlexPlugin

com.example.mymod.MyPlugin

Discovery and callback order (this decides what you can do in which callback):

Control Flex startup
  └── initializeClient()
        ├── providers injected  → isAvailable() becomes true
        └── built-in configs extracted to default/

  └── onKeyMappingsReady()
        └── discover SPI plugins
              ├── new CompatAssetInstaller(plugin.class, compatDir)
              ├── plugin.onInstallCompatConfigs(installer)
              ├── new CompatAssetInstaller(plugin.class, guideDir)
              ├── plugin.onInstallGuideAssets(installer)
              └── plugin.onControlFlexReady()

  runtime
        ├── plugin.onControllerConnectionChanged(connected)
        └── plugin.cameraLookConsumer().consumeLook(yaw, pitch)   // per frame, if non-null

Installed JSON lands in compat/mods/: above default/, below user/ — so players can still override your defaults in the user layer, a bridge never locks them out. A same-named file is replaced wholesale; there is no field-level merge.

Multi-loader project layout

All five official bridges use the same three-module layout: the logic is written once in common/, and the loader-specific parts (mixin config, mod metadata, event bus) stay in forge/ and fabric/. Adding an MC version line then only touches metadata instead of copying the logic.

cfx-compat-mymod/
├── common/
│   ├── build.gradle               # compileOnly control-flex-api + the target mod
│   └── src/main/
│       ├── java/com/ifels/cfx/mymod/
│       │   ├── CfxMyModPlugin.java        # implements IControlFlexPlugin
│       │   └── MyModBridge.java           # the only place that imports the target mod
│       └── resources/
│           ├── META-INF/services/com.ifels.controlflex.api.IControlFlexPlugin
│           └── assets/cfx_compat_mymod/compat/mymod.json
├── forge/                          # mods.toml, mixin config, Forge events
└── fabric/                         # fabric.mod.json, mixin config, Fabric events

Two easy traps. First, the SPI service file has to live in common's resources/META-INF/services — keep it only in a loader module and Fabric will never see it. Second, either keep the target mod strictly compileOnly or write an explicit "target missing, exit quietly" branch, otherwise your bridge drags the whole game down with it.

Debugging and shipping

Common failures

Symptom Cause / fix
No plugin callback ever fires Wrong SPI path or class name, or getPluginId() returns an empty string and is dropped as a duplicate. Check the discovery pass in the [ModCompat] log.
null inside onControlFlexReady Control Flex registers its providers in stages; only after ready is everything available. In the earlier callbacks, stick to installing assets.
JSON installed but the keys never appear The file must sit in compat/mods/ and be named after the mod_id; a mod_versions[].version that does not match the installed mod version is skipped as well.
Mixin does nothing on Fabric Mixin configs must be declared in xxx.mixins.json and listed in fabric.mod.json's mixins field; NeoForge 1.20.5+ uses META-INF/neoforge.mods.toml.
Fights with another gamepad mod Only one gamepad backend may drive a given target mod. The Epic Fight bridge is mutually exclusive with Controlify — say so in your release notes.

Keep reading