Let’s dive right in — here’s what K-Event looks like in action. We’ll follow it up with a full breakdown, practical design reasoning, and insights into advanced capabilities.
// 1. Define your events
class PlayerJoinedEvent(val playerId: String, val username: String) : Event()
class PlayerLeftEvent(val playerId: String) : Event()
// 2. Create a listener that reacts to events
class GameListener : Listener {
@Register
fun onJoin(event: PlayerJoinedEvent?) {
configuration(event) {
priority = Priority.HIGH
}
println("${event.username} joined the game!")
}
@Register
fun onLeave(event: PlayerLeftEvent) {
println("Goodbye ${event.playerId}!")
}
}
// 3. Set up the event system and dispatch events
fun main() {
val manager = DefaultEventManager()
manager.register(GameListener())
manager.dispatch(PlayerJoinedEvent("123", "Fantamomo"))
manager.dispatch(PlayerLeftEvent("123"))
}
Let’s explore every part of this code so you understand not just how to use K-Event, but why each part works the way it does. We’ll also connect the lines of code to the architectural ideas behind the system.
class PlayerJoinedEvent(val playerId: String, val username: String) : Event()
An Event
is a class representing an action or occurrence in your application.
In K-Event, all events must extend the Dispatchable
class (Event
is a subclass of it) to be recognized by the event system.
Why inheritance? It allows:
- Consistent typing across handlers
- Support for subtyping (or restricting it via
disallowSubtypes
) - Marker-based discovery at runtime
This pattern ensures events are lightweight, fast to dispatch, and predictable.
You can add any number of fields to an event. There are no restrictions on how complex they can be. This design makes K-Event flexible enough for UI events, network callbacks, system messages, and more.
Events can be generic see Generic Events
For more infos about
Event
andDispatchable
: link
class GameListener : Listener
Listeners are just normal Kotlin classes, but they implement the Listener
interface, which is a marker interface.
That means it doesn’t declare any methods — it’s only used to indicate that a class contains event handlers.
Why use a marker interface?
- Makes it trivial to detect valid listeners via reflection
- Prevents accidental registration of irrelevant classes
- Keeps API surface clean and intuitive
This fits Kotlin’s idiomatic style and avoids verbose base classes.
A Listener could be any Kotlin type, but we recommend
class
orobject
Handlers must follow a strict but simple signature:
@Register
fun onJoin(event: PlayerJoinedEvent?)
- Must be annotated with
@Register
- Must have exactly one parameter (it could have more when using parameter injection)
- The parameter must be nullable (when using custom configuration) and extend
Dispatchable
- Must not have the
@JvmStatic
annotation
K-Event calls each handler one time in registration with null
, to extract the configuration.
If the parameter is non-nullable, it behaves the same as using emptyConfiguration
.
This enables per-handler configuration without requiring a separate config function or builder.
Although listener methods can be open, we strongly advise against it as it may lead to unexpected errors.
The configuration DSL is invoked only when the event argument is null
(i.e. during registration).
configuration(event) {
priority = Priority.HIGH
disallowSubtypes = true
exclusiveListenerProcessing = true
silent = true
ignoreStickyEvents = true
name = "JoinHandler"
}
It is
inline
, so no lambda generation at runtime
priority
: Determines handler execution order. Higher runs first.disallowSubtypes
: Iftrue
, only matches this exact event class.exclusiveListenerProcessing
: Prevents this handler from running concurrently. This applies perSharedExclusiveExecution
instance.silent
: Iftrue
, the handler will not preventDeadEvent
from being emitted.ignoreStickyEvents
: Iftrue
, the handler will not receive sticky events.name
: Optional debug label for this method.
The configuration is stored inside the manager’s registry and used every time this event is dispatched.
Using emptyConfiguration(event)
is equivalent to using the default configuration (priority = 0, etc.).
val manager = EventManager()
manager.register(GameListener())
EventManager
is a factory methode and comes from the optional k-event-manager
module. It returns a DefaultEventManager
instance. It provides:
- Listener scanning and registration
- Dispatch pipeline with priority sorting
- Optional injection (advanced use) see this
- Dead event monitoring
Calling register()
triggers:
- Discovery of
@Register
-annotated methods - Execution of those methods with
null
- Internal configuration capture
You can register as many listeners as needed, at any point in time.
manager.dispatch(PlayerJoinedEvent("123", "Fantamomo"))
The dispatch mechanism follows these steps:
- Locate all handlers for the event type (and optionally subtypes)
- Sort them by priority (higher first)
- Call each method with the real event
- If no handlers match, emit a
DeadEvent
Dispatch is synchronous by default — the method will return only after all handlers finish.
K-Event supports not only regular handlers but also suspend
handlers.
The event system automatically detects whether a method is suspend
and invokes it accordingly.
When manager.dispatch(event)
is called, the system:
- Collects all handlers that should be invoked for the event.
- Sorts them by their configured priority (highest first).
- Iterates through each handler in order:
- If the handler is regular (non-suspend), it is invoked immediately in the current thread.
- If the handler is
suspend
, it is launched in a new coroutine starting onDispatchers.Unconfined
. This allows the handler to begin execution immediately on the current thread—enabling it to modify the event before any further processing—then it.
This design lets suspend handlers start quickly and potentially mutate the event early, while the overall dispatch does not block on their completion.
Sometimes you need all handlers — including suspend
ones — to fully complete before continuing.
That’s what:
manager.dispatchSuspend(MyEvent(...))
is for.
In this mode:
- All handlers (regular &
suspend
) are executed in the configured priority order. - If a
suspend
handler is encountered, the manager waits for it to complete before moving on. - Only then will the next handler be invoked.
The result: a deterministic sequence where each handler finishes before the next one starts.
Handlers can receive an injected isWaiting: Boolean
parameter that indicates whether the caller is waiting for the handler to complete.
This value is false
when using dispatch
and true
when using dispatchSuspend
.
You can define arbitrary keys to extend the configuration system:
interface Cancellable {
var cancelled: Boolean
}
object MyKeys {
val ASYNC = Key("async", false)
val TIMEOUT = Key("timeout", 1000L)
val IGNORE_CANCELLED = Key("ignoreCancelled", false)
}
var <T> EventConfigurationScope<T>.ignoreCancelled: Boolean where T : Dispatchable, T : Cancellable
get() = getOrDefault(MyKeys.IGNORE_CANCELLED)
set(value) = set(MyKeys.IGNORE_CANCELLED, value)
Usage:
configuration(event) {
ignoreCancelled = true // But only when `event` is a instance of `Cancellable`
set(MyKeys.ASYNC, true)
}
Use these keys in your manager or plugin logic to modify behavior dynamically.
K-Event supports lambda-based registration:
manager.register(
PlayerJoinedEvent::class,
createConfigurationScope { priority = Priority.HIGH },
) { event ->
println("Welcome ${event.username}")
}
This feature is perfect for:
- Dynamic plugins
- Unit tests
- Simple app-specific logic
No boilerplate listener classes needed.
If an event is dispatched but no handlers exist, a DeadEvent
is emitted instead.
class DeadEventLogger : Listener {
@Register
fun onDead(event: DeadEvent<*>?) {
emptyConfiguration(event)
println("Unhandled event: ${event.event::class.simpleName}")
}
}
This lets you debug missing listeners, misconfigurations, or build fallback logic.
All handler invocations in K-Event use reflection under the hood (except lambda-based handlers). That means:
- Handler methods are not compiled as function references.
- Dispatch uses reflection to invoke the correct method at runtime.
- This makes registration lightweight, but dispatch slightly slower compared to inlined call sites.
If you want zero-reflection dispatch, use lambda-based handler registration:
manager.register(MyEvent::class) { event ->
println("Handled $event")
}
This avoids reflection entirely.
K-Event defines a base class called Dispatchable
. The Event
class — which you typically extend — is a subclass of Dispatchable
.
This structure exists to:
- Allow more abstract or special types of messages (e.g.
DeadEvent
) to participate in the dispatch system. - Enable listeners to register for all dispatched content using
Dispatchable
.
However, this also has a side effect:
A listener registered for
Event
will not receiveDeadEvent
, becauseDeadEvent
does not inherit fromEvent
, only fromDispatchable
.
Furthermore:
If any listener is registered for
Dispatchable
, then noDeadEvent
will ever be dispatched.
This is an intentional design decision: if you're listening to absolutely everything, there's no such thing as a "dead" event.
K-Event supports optional parameter injection in the k-event-manager
module. This means that beyond the first Event?
parameter, additional parameters may be injected automatically.
Example:
class AdvancedListener : Listener {
@Register
fun onEvent(
event: MyEvent?,
manager: EventManager, // Injected automatically
logger: Logger, // Injected if supported
@InjectionName("config") myConfig: MyConfig // Custom injection
) {
emptyConfiguration(event)
logger.info("Event received: $event")
}
}
You can extend the manager with your own parameter resolvers to inject services, configurations, or contextual data.
Every Injection has a name and a type, for example manager
is the name and EventManager
is the type, only if the type and the name agree,
the listener can be registered.
Sometimes you need in the function another variable name instead of the injection name,
if that happened you can use @InjectionName
, where the name in brackets is the name that is uses in the system.
There are 5 default injectable parameter:
manager: EventManager
: The instance of the EventManager is passed, which calls the handler.logger: Logger
: A instance for logging, allDefaultEventManager
have the same.scope: CoroutineScope
: It can be used to launch new coroutines.isWaiting: Boolean
: For non suspend handler it will always betrue
, for suspend handler:- when called with
dispatch
it isfalse
- when called with
dispatchSuspend
it istrue
- when called with
config: EventConfiguration<*>
: The configuration of the handler.isSticky: Boolean
: If the event is sticky. See Sticky Events.
The following example disables scope
and logger
:
EventManager(Settings.DISABLE_SCOPE_INJECTION.with(true) + Settings.DISABLE_LOGGER_INJECTION.with(true))
K-Event support generic events.
This means that an event that is generic (like DeadEvent) can be handled by listeners.
Example:
data class MyGenericEvent<T : Any>(val value: T) : Event()
class MyListener : Listener {
@Register
fun onMyEvent(event: MyGenericEvent<*>?) {
//...
}
@Register
fun onMyEventString(event: MyGenericEvent<String>?) {
//...
}
}
The DefaultEventManager
can not check the generic type at runtime.
In this case both of the listeners will be called when an event like MyGenericEvent<Int>
is called.
That is a problem because in onMyEventString
we want the event with String
but get it with Int
.
K-Event adds two new interfaces GenericTypedEvent
and SingleGenericTypedEvent
.
Listeners can use
*
,out T
,T
andin T
data class MyGenericEvent<T : Any>(val value: T) : Event(), SingleGenericTypedEvent {
override fun extractGenericType(): KClass<*> = value::class
}
class MyListener : Listener {
@Register
fun onMyEvent(event: MyGenericEvent<*>?) {
//...
}
@Register
fun onMyEventString(event: MyGenericEvent<String>?) {
//...
}
}
With SingleGenericTypedEvent
the event manager can check the type at runtime and when MyGenericEvent<Int>
is dispatched,
only onMyEvent
is called.
Warning:
If an event is generic and inherits from another generic event, and you listen to the parent event while a child event is fired,
the event manager currently cannot verify the generic type in this inheritance case.
This means the listener for the parent event might still be called even if the generic type does not match.
A solution for this is being worked on.
A sticky event is an event stored in the manager and automatically delivered to all newly registered listeners.
To mark an event as sticky, set the sticky
property in the DispatchConfig
class to true
:
manager.dispatch(MyEvent(...)) {
sticky = true
}
// or
val config = createDispatchConfig {
sticky = true
}
manager.dispatch(MyEvent(...), config)
When a new listener is registered, it will immediately receive the stored event.
Only the most recent sticky event of a given type is kept.
There is also a new injectable parameter isSticky: Boolean
that can be used to check whether an event is sticky.
The default function for creating a DefaultEventManager
is EventManager
, but there is more.
The EventManager
function takes Components, for example the default manager will ignore errors thrown in listeners,
if you want to log these errors, you can use a ExceptionHandler
.
object SysOutExceptionHandler : ExceptionHandler() {
override fun handle(exception: Throwable, listener: Listener?, methode: KFunction<*>?) {
exception.printStackTrace()
}
}
val manager = EventManager(SysOutExceptionHandler)
But there is more, do you want a custom injectable parameter.
val server: Server = ...
val parameter = ListenerParameterResolver.static("server", Server::class, server)
val dynamicParameter = ListenerParameterResolver.dynamic("time", Instant::class) { Clock.System.now() }
val manager = EventManager(parameter)
There are two types of ListenerParameterResolver
:
static
: The type does not change.dynamic
: The type can change (e.g. because of listeners). Note that a dynamic type must provide a default value (e.g.0
, empty, ...), which is uses when registering a listener, so that the signatur is completed.
class ServerListener : Listener {
@Register
fun onServerReload(event: ServerReloadEvent?, server: Server) {
...
}
}
You can add as many ListenerParameterResolver
as you want, but only one ExceptionHandler
.
There is also a SharedExclusiveExecution
component,
which can be used to prevent concurrent execution of handlers, when the are using exclusiveListenerProcessing
.
Every EventManager has its own SharedExclusiveExecution
instance,
but you can override when adding it to the manager, via the +
operator.
If you want to combine some components:
val sharedExecution = SharedExclusiveExecution()
val manager = EventManager(
SysOutExceptionHandler +
parameter +
dynamicParameter +
sharedExecution
)
Note: K-Event is currently not published to Maven Central.
To get started:
- Clone the GitHub repository
- Run
./gradlew publishToMavenLocal
- Add this to your
build.gradle.kts
:
// Required API module
implementation("com.fantamomo:k-event-api:1.0-SNAPSHOT")
// Optional manager (JVM only for now)
implementation("com.fantamomo:k-event-manager:1.0-SNAPSHOT")
You can also include it as a source module directly in your project if needed.
K-Event is designed from the ground up for multiplatform development:
Module | Platforms | Notes |
---|---|---|
k-event-api |
JVM, JS, Native | Lightweight, zero dependencies |
k-event-manager |
JVM only (for now) | Future versions will expand platform support |
You can use the api
module even in shared/common codebases.
Element | Description |
---|---|
Event |
Base class for custom events |
Listener |
Marker interface for event receiver classes |
@Register |
Marks a handler method |
EventManager |
Interface for event dispatch & registration |
DefaultEventManager |
Built-in manager implementation |
configuration(event) |
Registers config during handler registration |
Priority |
Enum & factory for handler execution order |
Key<T> |
Typed config extension mechanism |
DeadEvent |
Wraps unhandled events |
createConfigurationScope() |
Used with lambda handler registration |
- ✅ Kotlin-first, Kotlin-only design
- ✅ Type-safe with minimal reflection
- ✅ Works across JVM, JS, and Native (core module)
- ✅ Easy to extend with custom behavior
- ✅ Declarative configuration DSL
- ✅ Fully isolated — no global state or static handlers
- ✅ Production-ready performance
Version: 1.0-SNAPSHOT
Happy event handling!