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
- A separate repository, ControlFlexMC/control-flex-api, published through JitPack; currently 0.9.2 — the same source as 0.9.0, with 0.9.2 being a re-tag for JitPack.
- The artifact is Java 8 bytecode (class 52), so it loads on any JVM 8+; a bridge mod does not have to share the core mod's toolchain.
compileOnlyonly: Control Flex provides it at runtime. Do not bundle the API into your jar.- Scope: read controller input, query action states, push mod states, declare interactive overlays, take over the camera, install compat / guide assets.
// 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
- Log prefix
[ModCompat]: config loading, overriding and skip reasons all land there; use your own logger for bridge messages (LogManager.getLogger("my-mod-id")). - Guard the minimum API version with
requireApiVersion("0.8.8"), and declare Control Flex as amandatorydependency inmods.toml/fabric.mod.json. - API is
compileOnly; ship a separate JAR and do not shade it in, or you duplicate the core. - Keep the target mod
compileOnlytoo (CurseForge Maven / Modrinth Maven), and add a path that exits quietly when it is missing — that is how the vehicle bridge stays optional. - Multi-loader projects use three modules,
common/+forge/+fabric/: logic in common, mixin config and mod metadata per loader. - When you publish, state what it fixes, which mod versions and loaders you tested, whether it ships optional JSON defaults, and which mods it conflicts with.
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. |