LiquidBounce uses a modern, high-performance event system built on lambda handlers and coroutines. The system enables modules, features, and scripts to react to game events efficiently.
Architecture Overview
The event system consists of three main components:
- Event Classes - Define what happened
- EventManager - Dispatches events to listeners
- EventListener - Receives and handles events
EventManager
Location: event/EventManager.kt:279
The central dispatcher using FastUtil maps for optimal performance:
object EventManager {
private val registry: Map<Class<out Event>, EventHookRegistry<in Event>>
private val flows: Map<Class<out Event>, MutableSharedFlow<Event>>
fun <T : Event> callEvent(event: T): T
fun <T : Event> registerEventHook(eventClass: Class<out Event>, eventHook: EventHook<T>)
fun <T : Event> unregisterEventHook(eventClass: Class<out Event>, eventHook: EventHook<T>)
}
Event Base Classes
Location: event/Event.kt:29
// Base event
abstract class Event {
var isCompleted: Boolean = false
internal set
}
// Cancellable events
abstract class CancellableEvent : Event() {
var isCancelled: Boolean = false
private set
fun cancelEvent()
}
Event Types
LiquidBounce has 100+ event types registered in ALL_EVENT_CLASSES:
Game Loop Events
@Tag("game_tick")
class GameTickEvent : Event()
@Tag("game_render")
class GameRenderEvent : Event()
@Tag("world_render")
class WorldRenderEvent : Event()
Player Events
@Tag("player_tick")
class PlayerTickEvent : Event()
@Tag("player_move")
class PlayerMoveEvent(
val movement: Vec3d,
val type: MovementType
) : CancellableEvent()
@Tag("player_jump")
class PlayerJumpEvent : CancellableEvent()
Network Events
@Tag("packet")
class PacketEvent(
val packet: Packet<*>,
val origin: TransferOrigin
) : CancellableEvent()
@Tag("keyboard_key")
class KeyboardKeyEvent(
val keyCode: Int,
val scanCode: Int,
val action: Int,
val mods: Int
) : Event()
@Tag("mouse_button")
class MouseButtonEvent(
val button: Int,
val action: Int,
val mods: Int
) : Event()
Registering Event Handlers
Basic Handler
class MyModule : ClientModule("MyModule", Category.COMBAT), EventListener {
val moveHandler = handler<PlayerMoveEvent> { event ->
// Handle player movement
if (event.movement.y > 0) {
// Player is jumping/ascending
}
}
val packetHandler = handler<PacketEvent> { event ->
if (event.packet is UpdatePlayerPositionPacket) {
// Handle position update
}
}
}
Handler Priority
Control execution order with priority (lower = earlier):
// Runs first
val earlyHandler = handler<PlayerTickEvent>(priority = -100) { event ->
// Pre-processing
}
// Runs last
val lateHandler = handler<PlayerTickEvent>(priority = 100) { event ->
// Post-processing
}
Cancelling Events
val antiKnockback = handler<PacketEvent> { event ->
if (event.packet is UpdateVelocityPacket) {
event.cancelEvent() // Prevent knockback
}
}
Important: You can only cancel events that extend CancellableEvent. Attempting to cancel a regular Event will throw an exception.
Advanced Handler Patterns
One-Time Handlers
// Execute only once
val onceHandler = once<GameTickEvent> { event ->
println("This runs only once")
}
Repeated Handlers
// Execute exactly N times
val repeatedHandler = repeated<GameTickEvent>(times = 10) { event ->
println("This runs 10 times")
}
Until Condition
// Run until condition is met
val untilHandler = until<GameTickEvent> { event ->
tickCount++
tickCount >= 20 // Stop after 20 ticks
}
Computed Properties
Automatically update values based on events:
var ticksSinceEnabled by computedOn<GameTickEvent, Int>(0) { _, prev ->
prev + 1
}
override fun enable() {
ticksSinceEnabled = 0 // Reset on enable
}
Event Flows
Get reactive Kotlin Flow for any event:
import net.ccbluex.liquidbounce.event.eventFlow
class MyModule : ClientModule("MyModule", Category.COMBAT) {
init {
launch {
eventFlow<PlayerTickEvent>().collect { event ->
// Reactive event handling
}
}
}
}
Flows receive events after all EventHook handlers have executed, so event.isCompleted will always be true.
Pre-Allocated Registry
All event classes are registered at startup in ALL_EVENT_CLASSES array:
@JvmField
internal val ALL_EVENT_CLASSES: Array<Class<out Event>> = arrayOf(
GameTickEvent::class.java,
PacketEvent::class.java,
// ... 100+ events
)
This creates lookup tables ahead of time using FastUtil’s Reference2ObjectOpenHashMap for O(1) lookups.
Event Hook Registry
Location: event/EventHookRegistry.kt
Each event type has its own registry that:
- Uses array-based storage for minimal overhead
- Supports priority-based ordering
- Avoids boxing primitives
- Uses identity-based equality for fast removal
Event Listener Lifecycle
EventListener Interface
Location: event/EventListener.kt:33
interface EventListener : DebuggedOwner {
val running: Boolean
get() = parent()?.running ?: !isDestructed
fun parent(): EventListener?
fun children(): List<EventListener>
fun unregister()
}
Hierarchical Listeners
Modules form a hierarchy:
class ParentModule : ClientModule("Parent", Category.COMBAT) {
val child = tree(ChildModule())
override fun running() = enabled && super.running
}
class ChildModule : ChoiceConfigurable("Child") {
override fun parent() = thisModule as EventListener
}
When the parent is disabled, child handlers don’t execute.
Error Handling
The event system catches and logs exceptions:
try {
eventHook.handler.accept(event)
} catch (e: ReportedException) {
ErrorHandler.fatal(
error = e,
needToReport = true,
additionalMessage = "Event handler of ${eventHook.handlerClass}"
)
} catch (e: Throwable) {
logger.error("Exception while executing event handler", e)
}
Exception Safety: Exceptions in one handler won’t prevent other handlers from executing. Always handle errors gracefully within your handlers.
Mixin Integration
Events are fired from mixins injected into Minecraft code:
@Mixin(ClientPlayerEntity.class)
public class MixinClientPlayer {
@Inject(method = "tick", at = @At("HEAD"))
private void onTick(CallbackInfo ci) {
EventManager.callEvent(new PlayerTickEvent());
}
}
See Mixin System for details on how events are hooked.
Coroutine Support
Suspend Handlers
Location: event/SuspendHandlers.kt
Handlers can suspend for async operations:
val suspendHandler = handler<PacketEvent> { event ->
coroutineScope {
// Async work
val result = async { heavyComputation() }
// Handle result
}
}
Ticker System
Location: event/CoroutineTicker.kt
Run coroutines at regular intervals:
object CoroutineTicker {
fun tickFor(context: CoroutineContext, intervalMs: Long, task: suspend () -> Unit)
}
Best Practices
Minimize Handler Work
// Good - fast check first
val handler = handler<PacketEvent> { event ->
if (event.packet !is UpdatePlayerPositionPacket) return@handler
// Heavy processing only for relevant packets
}
// Bad - always does expensive work
val handler = handler<PacketEvent> { event ->
val result = expensiveOperation()
if (event.packet is UpdatePlayerPositionPacket) {
useResult(result)
}
}
Unregister When Done
val tempHandler = handler<GameTickEvent> { event ->
if (condition) {
EventManager.unregisterEventHook(GameTickEvent::class.java, tempHandler)
}
}
Use Appropriate Event Types
Choose the most specific event:
// Good - specific event
handler<PlayerJumpEvent> { /* ... */ }
// Bad - filtering generic event
handler<PlayerMoveEvent> { event ->
if (event.movement.y > 0 && player.isOnGround) {
// Detecting jumps manually
}
}