Implementations
Five bridges, five problems. Each one follows the same order: why JSON is not enough → what the bridge does → requirements and traps → where the source lives. The second half distils their shared techniques into five reusable snippets.
Section: Overview · Setup & lifecycle · Interfaces · Implementations · Who uses the API
The five official bridges
Compatibility work comes in three kinds, all under the ControlFlexMC org: the core mod (built-in JSON + dispatch channels), the config source (distribution of community JSON), and bridges — the 5 that actually write Java.
| Repository | Adapts | Branches · loaders | Technique |
|---|---|---|---|
| control-flex | Every standard KeyMapping mod | Core mod, every version line | 3 built-in JSON files + five dispatch channels + three-layer override |
| cfx-compat-configs | Mods plain JSON can cover | Tagged per ControlFlex release | Community config source, see compat config |
| cfx-compat-epicfight | Epic Fight | 1.20.1 Forge · 1.21.1 NeoForge | Registers as EF's controller mod + mixin rewrites binding lookup + state push + camera takeover |
| cfx-compat-create | Create train console | 1.20.1 Forge / Fabric · 1.21.1 NeoForge | A single injection point that merges movement impulses into key checks |
| cfx-compat-exposure | Exposure viewfinder | 1.20.1 Forge / Fabric · 1.21.1 Fabric / NeoForge | Declares an interactive overlay's foreground / background |
| cfx-compat-emi | EMI | 1.18.2 / 1.19.2 / 1.20.1 / 1.21.1 | Registers 22 pseudo KeyMappings and forwards them into EMI's own functions |
| cfx-compat-vehicles | Immersive Vehicles (MTS) and other GLFW-polling vehicle mods | 1.12.2 → 1.21.1, 26.1.2 (Forge line) | Mixin inside the vehicle mod's own key read path |
cfx-compat-epicfight: making Control Flex EF's controller backend
Epic Fight does not read KeyMapping; it has its own input SPI (IEpicFightControllerMod). With nothing registered it falls back to raw GLFW keyboard state, which a gamepad binding can never satisfy. It also looks up a ControllerBinding per action, and only the implementor can supply that object — JSON cannot.
So the bridge does three things: implements IEpicFightControllerMod (stick → PlayerInputState, actions → ControllerBinding); mixins over EF's controllerBinding() to redirect binding lookup into Control Flex's action state; listens for ChangePlayerModeEvent and pushes epicfight:battle_mode. On top of that, EpicFightCameraBridge implements camera takeover — turn deltas go to EpicFightCameraAPI.turnCamera(), so the view tracks the stick while EF owns the camera (TPS combat camera, lock-on).
Requires ControlFlex ≥ 0.8.8; Epic Fight ≥ 20.14 (1.20.1 Forge) / ≥ 21.15 (1.21.1 NeoForge). Incompatible with Controlify — both want to be EF's controller mod and only one can win. Source entry points: CfxEpicFightControllerMod, EpicFightStateBridge, EpicFightCameraBridge, mixin/EpicFightInputActionMixin.
cfx-compat-create: one injection point for train throttle
Create's train console does not read KeyMapping.isDown(); it walks all the way down to InputConstants.isKeyDown(window, code). Control Flex's analog movement takes over the MOVE group — those actions stop being dispatched and the stick value goes straight into player.input.forwardImpulse — so the player walks, but no key press is ever published and the train never gets throttle.
The fix is deliberately minimal: a single RETURN injection on ControlsUtil.isActuallyPressed(KeyMapping) merges forwardImpulse / leftImpulse into the forward / back / left / right checks; sneak → brake, jump → horn. When the real key is already down the original result is returned untouched, so keyboard priority survives.
Note it writes no global state: no synthetic GLFW poll state, no KeyMapping writes, no Forge key events, so other mods are unaffected. It needs no "is the player driving?" check either, because only the train console calls isActuallyPressed. Source entry points: CreateTrainControl, mixin/ControlsUtilMixin.
cfx-compat-exposure: context declaration only
Exposure's viewfinder is an interactive overlay that grabs the camera and hides the HUD. Control Flex only switches stick behavior for contexts it knows about, so the bridge just reports: mixins on CameraClient.setupViewfinder / removeViewfinder — the two chokepoints every open/close path goes through — call notifyOverlayForeground / notifyOverlayBackground.
The detail is in pairing: the bridge keeps its own foreground flag, so removeViewfinder without an active viewfinder never emits a stray background; switching cameras emits background → foreground in that order; logging out resets state and emits one final background. Requires ControlFlex ≥ 0.8.7 (0.8.7 renamed these two methods) and Exposure ≥ 1.9.20.
cfx-compat-emi: 22 pseudo KeyMappings
EMI stores its keybinds in its own config (binds.*) and matches them inside its own screens — it registers no Minecraft KeyMapping, so its actions appear neither in vanilla Controls nor in controller auto-discovery. The bridge registers 22 pseudo KeyMappings for Control Flex to discover and forwards each press into EMI's own interaction functions: no fake input, and EMI's key settings are left untouched.
| Group | Actions |
|---|---|
| Screen | Toggle EMI, focus search, clear search |
| Browse | View recipes / uses, add to favourites, recipe tree, stack tree, forward / back |
| Craft | Craft one / a stack, to inventory (one / stack), to cursor (one) |
| Creative | Cheat one / a stack to inventory or cursor |
| Manage | Hide item, hide by ID, copy recipe ID |
All of them live in the GUI layer and show up as "Control Flex x EMI" in the binding list. Most act on the hovered stack, so point at the item first; while a text field has focus, or EMI is switched off or reloading, only "Toggle EMI" fires. Not supported: defaultStack, deleteCursorStack, showCraft (the first two are inline logic deep inside the recipe screen, the third is a held state rather than an action), displayAllRecipes (not in the released jar), and "Copy Recipe ID" on a recipe screen (its context sits in a private field). It never rewrites EMI's own binds.* either.
cfx-compat-vehicles: vehicles only speak GLFW
Immersive Vehicles (MTS) reads input through GLFW.glfwGetKey(window, keyCode) — it looks at neither KeyMapping nor InputConstants, so keyMapping / virtualKbm / glfwPoll all fail to reach it. The GLFW layer itself is not injectable on Forge (library classes are loaded by the system class loader and never pass through ModLauncher), so the mixin has to sit in the vehicle mod's own read path: InterfaceInputMixin hooks mcinterface1201.InterfaceInput#isKeyPressed and answers true for the four movement keys.
The signal is still the player's own forwardImpulse / leftImpulse (including the analog response curve), falling back to action states when they are zero, so it keeps working with analog movement off; nothing is polled, no global key state is written, and a physically pressed key always wins. It ships no JSON: it reads the core mod's signals directly, so there is no rule set to keep in sync with the config format. Known limits: IV's own keybinds (car.gas, …) are ignored, so reset them to WASD if you changed them; do not bind IV's built-in joystick, which sets kbOverride and disables the bridge; the mixin targets a version-stamped package (mcinterface1201), so another MC version needs its own build.
Bridge and JSON are not either/or: most bridges also install a JSON file into the mods/ layer in onInstallCompatConfigs — letting JSON decide where keys go and Java decide what happens at runtime. In the community source the create / emi configs go the other way and use dependencies to require the bridge.
Five techniques, five real examples
1) Keys are not registered at all: add pseudo KeyMappings (EMI)
Register ordinary KeyMappings so auto-discovery sees them, then forward the press into the mod's own entry point — no fake input, no changes to the mod's key settings.
// Register a pseudo key mapping (registration API differs per MC version / loader)
new KeyMapping("key.cfx_target.open", GLFW.GLFW_KEY_K, "key.categories.cfx_target");
// Each tick, forward Control Flex's action state into the mod's own entry point
if (ControlFlexApi.getActionStateProvider()
.isGameActionActive("cfx_target:key.cfx_target.open")) {
TargetMod.openPanel(); // call the mod's function, do not synthesize a key press
}
2) The mod reads raw key state: mixin into its read point (vehicles / Create)
The glfwPoll channel covers polling through InputConstants.isKeyDown, but mods that call GLFW.glfwGetKey never see it — GLFW is loaded by the system class loader and never passes through ModLauncher's transformer. Instead, put a mixin on the mod's own read entry point and answer "is it held right now".
// Only answer for the four movement keys; everything else keeps the original logic
@Mixin(TargetInput.class)
public abstract class TargetInputMixin {
@Inject(method = "isKeyPressed", at = @At("HEAD"), cancellable = true)
private void cfx$steering(int keyCode, CallbackInfoReturnable<Boolean> cir) {
if (ControlFlexApi.isAvailable()
&& ControlFlexApi.getActionStateProvider().isGameActionActive("move_forward")) {
cir.setReturnValue(true);
}
}
}
Keeping that mixin inside a separate bridge mod keeps the core free of third-party dependencies, and a failure only affects that one compat package.
3) The mod has runtime state: push a state and let JSON consume it (Epic Fight)
Epic Fight's battle mode decides which keys the mod should own. Java only pushes the state; which actions get suppressed is written in JSON — so players and pack authors can change it.
// Java: push once whenever the state flips
ControlFlexApi.getPlayerStateRegistry()
.setState("epicfight:battle_mode", inBattle);
// JSON: consume it as a condition (holding a katana in battle mode: suppress generic use)
"itemSuppressKeys": [
{ "item": "efn:yamato_dmc*",
"playerState": ["epicfight:battle_mode"],
"suppressActions": ["use"] }
]
4) Custom overlays: declare foreground / background (Exposure)
A self-drawn HUD is not a Screen, so Control Flex cannot see it. Report once on open and once on close and the core mod switches stick and cursor behavior in the Overlay phase using the JSON overlay table; the class name must match the overlay key.
public final class OverlayNotifier {
public static void onOpened(Object overlay) {
IInteractiveContextRegistrar registrar = ControlFlexApi.getInteractiveContextRegistrar();
if (registrar != null) {
registrar.notifyOverlayForeground(overlay.getClass().getName());
}
}
public static void onClosed(Object overlay) {
IInteractiveContextRegistrar registrar = ControlFlexApi.getInteractiveContextRegistrar();
if (registrar != null) {
registrar.notifyOverlayBackground(overlay.getClass().getName());
}
}
}
Paired notifications are mandatory: foreground without background leaves stick behavior stuck in overlay mode; use clearAll() as the fallback when leaving the world.
5) A mod took the camera: consume the look delta
Once the camera is taken over, writing yRot/xRot changes nothing on screen. Implement ICameraLookConsumer and hand the delta to whoever owns the camera; returning true means the frame is consumed and the core mod will not write player rotation.
@Override
public ICameraLookConsumer cameraLookConsumer() {
return (yaw, pitch) -> {
if (!ControlFlexApi.isAvailable()) return false;
return TargetCameraApi.getInstance().turnCamera(yaw, pitch);
};
}