Kotlin-first codegen for Minecraft internals — access the inaccessible, inject behavior, and write clean Kotlin. Enchant your mod with powerful extensions — that’s the magic of Lapis!
dependencies {
// Add Lapis as a compileOnly dependency via KSP
// Check latest version on Maven Central badge above
ksp("io.github.recrafter:lapis:$version")
}
ksp {
// Required: specify your mod ID
arg("lapis.modId", "cobbble")
// Required: specify your mod package name (maven group)
arg("lapis.packageName", "io.github.recrafter.cobbble")
}ksp.arg("lapis.modId", ...)
This is used to namespace generated bridge methods and extensions.
Lapis turns your annotations into clean, type-safe, inline Kotlin APIs, tailored for Minecraft modding. These Kotlin extensions are the primary developer-facing feature generated by Lapis.
| Feature | Annotation | Purpose |
|---|---|---|
| Runtime patching | @Patcher |
Inject custom logic and state into existing code |
| JVM access | @Accessor |
Access private/protected fields and methods |
| Kotlin aliases | @Alias |
Rename & wrap accessible members for clarity |
Use @Patch to attach custom state and behavior to an existing class at runtime.
This is ideal for scenarios where you need per-instance logic—like tracking UI state, extending functionality, or
hooking into lifecycle events.
Unlike @Accessor, @Patch doesn't just expose internals—it adds new Kotlin logic directly to target instances
using Mixins, bridges, and auto-generated extension APIs.
In this example, we patch MinecraftClient to track whether a custom overlay is shown, and reset it when a new screen
opens.
@Patch(target = MinecraftClient::class)
abstract class MinecraftClientPatch : LapisPatch<MinecraftClient>() {
var somePublicVar: Int = -1
private var isOverlayShown: Boolean = false
fun openOverlay() {
isOverlayShown = true
}
fun closeOverlay() {
isOverlayShown = false
}
@Hook(Kind.Method)
private fun onSetScreen(
@Function function: (MinecraftClient.(Screen?) -> Unit)? = MinecraftClient::setScreen,
screen: Screen?
) {
if (screen != null) {
closeOverlay()
}
}
}This is an abstract Kotlin class, not a mixin. It behaves like a component that is automatically attached to
each instance of the target class (MinecraftClient).
- You write regular Kotlin methods and fields.
@Hook(WIP) can be used to intercept target methods.- The
Patch<T>superclass ensures the patch is scoped to the target instance.
Lapis transforms your patch into a fully integrated runtime extension, consisting of:
class MinecraftClientPatch_Impl(
override val target: MinecraftClient
) : MinecraftClientPatch()This holds your patch state and methods.
Each MinecraftClient instance will lazily attach its own ClientPatch_Impl.
@Mixin(MinecraftClient.class)
public class MinecraftClientPatch_Mixin implements MinecraftClientPatch_Bridge {
@Unique
private MinecraftClientPatch_Impl patch;
@Unique
private MinecraftClientPatch_Impl getOrInitPatch() {
if (patch == null) {
patch = new MinecraftClientPatch_Impl(((MinecraftClient) ((Object) this)));
}
return patch;
}
@Override
public int modid_getSomePublicVar() {
return getOrInitPatch().getSomePublicVar();
}
@Override
public void modid_setSomePublicVar(int newValue) {
getOrInitPatch().setSomePublicVar(newValue);
}
@Override
public void modid_openOverlay() {
getOrInitPatch().openOverlay();
}
@Override
public void modid_closeOverlay() {
getOrInitPatch().closeOverlay();
}
// TODO Example of generated @Hook-based mixin injection
}The mixin is injected into the target class.
It holds the patch instance and forwards calls to it.
inline var MinecraftClient.somePublicVar: Int
get() = (this as MinecraftClientPatch_Bridge).modid_getSomePublicVar()
set(newValue) {
(this as MinecraftClientPatch_Bridge).modid_setSomePublicVar(newValue)
}
inline fun MinecraftClient.openOverlay() {
(this as MinecraftClientPatch_Bridge).modid_openOverlay(s)
}
inline fun MinecraftClient.closeOverlay() {
(this as MinecraftClientPatch_Bridge).modid_closeOverlay(s)
}You can now write:
client.somePublicVar = 100
client.openOverlay()…with no knowledge of the patch class, bridge, or mixin.
It's just Kotlin—and it's type-safe, inline, and zero-cost.
- You write a patch class that extends
Patch<T>. - Lapis generates:
- A Kotlin
*_Implclass (your patch logic) - A Java Mixin class injected into the target
- A
*_Bridgeinterface to define exposed members - Kotlin extensions that forward through the bridge
- A Kotlin
- Each target instance (e.g.,
MinecraftClient) lazily attaches a patch instance. - Extensions resolve the correct patch via the mixin bridge and call its methods.
@Hooksupport is still WIP, but the structure is already in place.- Patch members must be non-private to be accessible through extensions.
- You can use properties, methods, and even lifecycle handlers—everything behaves like normal Kotlin.
| Feature | Purpose |
|---|---|
@Patch |
Define new behavior or state for existing classes |
*_Impl class |
Your actual patch logic, in idiomatic Kotlin |
| Java Mixin + Bridge | Inject patch and expose it to Kotlin extensions |
| Kotlin extensions | Clean, inline APIs with no reflection or boilerplate |
Use @Accessor when you need access to private or protected fields, methods, or constructors in Minecraft classes.
Lapis generates all the necessary Mixin boilerplate and exposes everything through safe, inline Kotlin extensions.
This eliminates the need for reflection or unsafe casting, while keeping your code clean and idiomatic.
Example — Access Hidden APIs
This accessor exposes:
- a private field (
window), - a private method (
doTick()), - a private constructor.
@Accessor(
widener = "net.minecraft.client.MinecraftClient",
target = MinecraftClient::class,
)
interface MinecraftClientAccessor {
@AccessField
val window: Window
@AccessMethod
fun doTick()
@AccessConstructor
fun create()
}Each member is abstract and annotated to indicate what it accessed.
Lapis automatically handles the Mixin wiring and Kotlin API generation.
This interface is injected at runtime using Sponge Mixin.
@Mixin(MinecraftClient.class)
public interface MinecraftClientAccessor_Mixin {
@Accessor("window")
Window getWindow();
@Invoker("doTick")
void invokeDoTick();
@Invoker("<init>")
static MinecraftClient create();
}These accessors/invokers provide raw JVM access to hidden fields, methods, and constructors.
Lapis wraps the accessors with inline Kotlin extension functions:
inline val MinecraftClient.window: Window
inline fun MinecraftClient.doTick()These are safe and feel like native APIs—no need for reflection or casting.
Static methods and constructors are accessed via a generated Kotlin object:
object MinecraftClientKFactory {
val window: Window
fun create(): MinecraftClient
}This ensures clean separation of static vs instance APIs.
You can now write:
val client = MinecraftClientKFactory.create()
client.doTick()
client.windowJust like you would with regular public APIs.
When the target class is not public, you must provide a widener string.
Lapis uses this to generate entries in:
META-INF/lapis/wideners.txt
Nested accessors combine wideners automatically:
@Accessor(widener = "net.minecraft.Outer")
interface OuterAccessor {
@Accessor(widener = ".Inner")
interface InnerAccessor
}➡️ Result: net.minecraft.Outer$Inner
| Feature | Purpose |
|---|---|
@AccessField |
Exposes private or protected fields |
@AccessMethod |
Exposes private or protected methods |
@AccessConstructor |
Exposes hidden constructors |
| Kotlin extensions | Inline, type-safe access to accessed members |
| Factory objects | Clean APIs for static fields and constructors |
widener |
Enables access to internal or package-private classes |
Lapis turns internals into idiomatic, safe, Kotlin-first APIs—with no reflection and no boilerplate.
Use @Alias when the target class is already accessible, and you want to wrap or rename its members using clean,
idiomatic Kotlin.
Unlike @Accessor, @Alias does not use Mixins.
It simply generates inline Kotlin extension functions and properties that redirect to public fields and methods,
making your code more readable and maintainable.
Here we define a new name for a public field and method:
@Alias(target = MinecraftClient::class)
interface MinecraftClientAlias {
@FieldAlias("window")
val mainWindow: Window
@MethodAlias("setScreen")
fun openScreen(screen: Screen)
}This doesn’t change behavior—it just gives you a more expressive Kotlin-facing API.
To ensure predictable code generation, the following rules apply:
- The annotated type must be an interface
- It must be a top-level (root) interface
- Each property must use
@FieldAlias - Each function must use
@MethodAlias - All members must be abstract
- The target class must be already visible (i.e., no
@Accessorneeded)
Given the example above, Lapis generates the following in:
MinecraftClientExt.kt
inline val MinecraftClient.mainWindow: Window
inline fun MinecraftClient.openScreen(screen: Screen)These extensions redirect to the original member (window, setScreen) but use your alias names (mainWindow,
openScreen).
They’re inline, type-safe, and require no reflection or runtime overhead.
If you specify the typeAlias parameter on @Alias, Lapis will also generate a Kotlin typealias for the target
class:
typealias GameClient = MinecraftClientThis is useful when you want to abstract implementation details or unify references across modules.
| Use Case | Use |
|---|---|
| Access private or protected | @Accessor |
| Rename or wrap public members | @Alias |
| Add new state and behavior | @Patch |
Lapis gives you ergonomic, Kotlin-first access to existing APIs—no Mixins, no reflection, no mess.
This project is licensed under the MIT License.