Kotlin Native Game Engine (KNGN?)
Kengine is a lightweight, game framework built in Kotlin Native + SDL3, designed for easy/simple game development.
The project is still in early dev mode, contributors welcome.
This has also been an experiment with ChatGPT + Claude to help with coding + design mixed with some other hobby projects.
A Simple Example
fun main() {
useContext(
GameContext.create(
title = "Bouncing Ball Game",
width = 800,
height = 600
)
) {
GameRunner(frameRate = 60) {
BouncingBallGame()
}
}
}
class BouncingBallGame : Game {
private val ball = BallSpriteEntity()
override fun update() {
ball.update()
}
override fun draw() {
useSDLContext {
fillScreen(0u, 0u, 0u) // or fillScreen(Color.black)
ball.draw()
flipScreen()
}
}
override fun cleanup() {
ball.cleanup()
}
}
class BallSpriteEntity : SpriteEntity(
sprite = Sprite.fromFilePath("assets/sprites/ball.bmp"),
p = Vec2(400.0, 300.0), // initial position
v = Vec2(30.0, 30.0) // initial velocity (pixels/sec)
), Logging {
private var bounceCounter = 0
override fun update() {
val clock = getContext<ClockContext>()
// update position
p += v * clock.deltaTimeSec
// handle screen boundaries
useSDLContext {
if (p.x < 0 || p.x + width > screenWidth) {
direction.x *= -1
bounceCounter++
}
if (p.y < 0 || p.y + height > screenHeight) {
direction.y *= -1
bounceCounter++
}
}
useKeyboardContext {
if (keyboard.isRPressed()) {
logger.info { "Reset ball" }
p.set(400.0, 300.0)
p.set(30.0, 30.0)
}
}
logger.info { "Wall bounces: $bounceCounter" }
}
}A sample game app to help get started.
A more robust example with keyboard and controller support.
Controls:
- WASD or Arrows: Movement
- R: Reset level
- Return: Next level
- Space: Previous level
There are 41 levels total.
fun main() {
createGameContext(
title = "Boxxle",
width = 800,
height = 600
) {
GameRunner(frameRate = 60) {
BoxxleGame()
}
}
}Explore sound synthesis with a3x Oscillator and a variety of visual effects. This also showcases the UI library and state handling via useState.
Textures are central to rendering 2D graphics in Kengine. They are managed using the TextureManager, which caches textures for efficient reuse.
Example: Loading and Using a Texture
useTextureContext {
addTexture("ball", "assets/sprites/ball.bmp")
val ballTexture = getTexture("ball")
}Sprites represent drawable objects on the screen. They can be created from textures or sprite sheets and drawn with transformations like scaling and rotation.
Example: Drawing a Sprite
val sprite = Sprite.fromFilePath("assets/sprites/ball.bmp")
sprite.draw(x = 100.0, y = 200.0)Animated sprites cycle through a sequence of images (frames) to create animations. The AnimatedSprite class makes this process straightforward.
Example:
val animatedSprite = AnimatedSprite(
sprites = listOf(
Sprite.fromFilePath("samus01.bmp"),
Sprite.fromFilePath("samus02.bmp"),
Sprite.fromFilePath("samus03.bmp")
),
frameRate = 10
)
animatedSprite.draw(Vec2(100.0, 200.0))Another example loading from a SpriteSheet.
val spriteSheet = SpriteSheet.fromFilePath("assets/sprites/metroid.bmp", tileWidth = 32, tileHeight = 32)
private val animatedMetroid = AnimatedSprite.fromSpriteSheet(spriteSheet, frameDurationMs = 200L)Kengine provides simple geometry drawing utilities such as circles, rectangles, and lines, through the GeometryContext.
Example:
useGeometryContext {
drawRectangle(50, 50, 100, 200, 0xFFu, 0x00u, 0x00u, 0xFFu)
drawCircle(200, 200, 50, 0x00u, 0xFFu, 0x00u, 0xFFu)
drawLine(0, 0, 300, 300, 0x00u, 0x00u, 0xFFu, 0xFFu)
}The ClockContext handles game time, providing delta times for updates and total elapsed time since the game started.
Example: Using the ClockContext
useClockContext {
logger.info { "Total Time: $totalTimeSec seconds" }
logger.info { "Delta Time: $deltaTimeSec seconds" }
}The MouseContext provides utilities to handle mouse input events, including button presses, cursor position, and timing.
Example: Handling Mouse Input
useMouseContext {
if (mouse.isLeftPressed() || mouse.isRightPressed()) {
p.x = mouse.getCursor().x - width / 2
p.y = mouse.getCursor().y - height / 2
}
}Mouse Functions
| Function | Description |
|---|---|
mouse.isLeftPressed() |
Returns true if the left mouse button is pressed. |
mouse.isRightPressed() |
Returns true if the right mouse button is pressed. |
mouse.isMiddlePressed() |
Returns true if the middle mouse button is pressed. |
mouse.getCursor() |
Returns the current cursor position as Vec2(x, y). |
mouse.timeSinceLeftPressed() |
Returns time (ms) since the left mouse button was pressed. |
mouse.timeSinceRightPressed() |
Returns time (ms) since the right mouse button was pressed. |
mouse.timeSinceMiddlePressed() |
Returns time (ms) since the middle mouse button was pressed. |
The KeyboardContext provides utilities for handling keyboard input, including key presses and timings.
Example: Handling Keyboard Input
useKeyboardContext {
if (keyboard.isWPressed()) {
logger.info { "Moving up!" }
}
if (keyboard.isReturnPressed()) {
logger.info { "Return key pressed!" }
}
}Keyboard Functions
| Function | Description |
|---|---|
keyboard.isAPressed() |
Returns true if the A key is pressed. |
keyboard.isSpacePressed() |
Returns true if the Space key is pressed. |
keyboard.isReturnPressed() |
Returns true if the Return/Enter key is pressed. |
keyboard.isEscapePressed() |
Returns true if the Escape key is pressed. |
keyboard.isLeftPressed() |
Returns true if the Left Arrow key is pressed. |
keyboard.isRightPressed() |
Returns true if the Right Arrow key is pressed. |
keyboard.timeSinceAPressed() |
Returns time (ms) since the A key was pressed. |
keyboard.timeSinceSpacePressed() |
Returns time (ms) since the Space key was pressed. |
keyboard.timeSinceReturnPressed() |
Returns time (ms) since the Return/Enter key was pressed. |
The ControllerContext handles input from game controllers, supporting PlayStation, Xbox, Nintendo Switch, and generic gamepads.
Example: Handling Controller Input
useControllerContext {
if (controller.isButtonPressed(Buttons.A)) {
logger.info { "Jump button pressed!" }
}
val axisValue = controller.getAxisValue(0) // Read the left stick horizontal axis
logger.info { "Axis value: $axisValue" }
}Supported Controllers
- PlayStation 4 (DualShock 4)
- PlayStation 5 (DualSense)
- Xbox One
- Xbox Series X/S
- Nintendo Switch Pro Controller
- Logitech
- Ouya
- Steam Controller (needs more testing)
- Generic Gamepads (fallback mapping)
Controller Functions
| Function | Description |
|---|---|
controller.isButtonPressed(Buttons.A) |
Returns true if the A button is pressed. |
controller.isButtonPressed(Buttons.B) |
Returns true if the B button is pressed. |
controller.isButtonPressed(Buttons.START) |
Returns true if the Start/Options button is pressed. |
controller.isButtonPressed(Buttons.DPAD_UP) |
Returns true if the D-Pad Up is pressed. |
controller.getAxisValue(0) |
Gets the value of the Left Stick X-Axis (-1.0 to 1.0). |
controller.getAxisValue(1) |
Gets the value of the Left Stick Y-Axis (-1.0 to 1.0). |
controller.isHatDirectionPressed(0, HatDirection.UP) |
Returns true if the D-Pad Up direction is pressed. |
Controller Buttons Overview
Controller Button Mapping Table:
| Button | Code Example | PlayStation 5 | Xbox Series X |
|---|---|---|---|
| Buttons.A | controller.isButtonPressed(Buttons.A) |
X | A |
| Buttons.B | controller.isButtonPressed(Buttons.B) |
Circle (O) | B |
| Buttons.X | controller.isButtonPressed(Buttons.X) |
Square (□) | X |
| Buttons.Y | controller.isButtonPressed(Buttons.Y) |
Triangle (△) | Y |
| Buttons.L1 | controller.isButtonPressed(Buttons.L1) |
L1 | LB (Left Bumper) |
| Buttons.R1 | controller.isButtonPressed(Buttons.R1) |
R1 | RB (Right Bumper) |
| Buttons.L2 | controller.getAxisValue(4) |
L2 Trigger Axis | LT (Left Trigger) |
| Buttons.R2 | controller.getAxisValue(5) |
R2 Trigger Axis | RT (Right Trigger) |
| Buttons.L3 | controller.isButtonPressed(Buttons.L3) |
L3 (Left Stick Button) | LS (Left Stick Button) |
| Buttons.R3 | controller.isButtonPressed(Buttons.R3) |
R3 (Right Stick Button) | RS (Right Stick Button) |
| Buttons.START | controller.isButtonPressed(Buttons.START) |
Options | Menu (Start) |
| Buttons.SELECT | controller.isButtonPressed(Buttons.SELECT) |
Create (Share) | View (Back) |
| Buttons.DPAD_UP | controller.isButtonPressed(Buttons.DPAD_UP) |
D-Pad Up | D-Pad Up |
| Buttons.DPAD_DOWN | controller.isButtonPressed(Buttons.DPAD_DOWN) |
D-Pad Down | D-Pad Down |
Controller Axes Overview
| Axis Name | Code Example | PlayStation 5 | Xbox Series X |
|---|---|---|---|
| Left Stick X | controller.getAxisValue(0) |
Left Stick Horizontal Axis | Left Stick Horizontal Axis |
| Left Stick Y | controller.getAxisValue(1) |
Left Stick Vertical Axis | Left Stick Vertical Axis |
| Right Stick X | controller.getAxisValue(2) |
Right Stick Horizontal Axis | Right Stick Horizontal Axis |
| Right Stick Y | controller.getAxisValue(3) |
Right Stick Vertical Axis | Right Stick Vertical Axis |
| Left Trigger Axis | controller.getAxisValue(4) |
L2 Trigger Axis | LT Trigger Axis |
| Right Trigger Axis | controller.getAxisValue(5) |
R2 Trigger Axis | RT Trigger Axis |
Controller Modes
Kengine supports two controller input modes that can be configured via environment variable:
JOYSTICK Mode (Default)
- Uses custom per-controller mappings (SNES, PS4/PS5, Xbox, Nintendo Switch, etc.)
- Maximum compatibility with all controller types
- D-pad may use HAT events on some controllers
- Games work with existing controller mappings without changes
GAMEPAD Mode
- Uses SDL3's standardized gamepad layer
- Automatic button/axis normalization across all controllers
- Fixes duplicate controller detection on macOS
- Single unified mapping for all hardware
- Recommended for new games
Set the mode using the KENGINE_CONTROLLER_MODE environment variable:
# Default: JOYSTICK mode (custom mappings)
./myGame.kexe
# GAMEPAD mode (SDL3 standardized)
export KENGINE_CONTROLLER_MODE=gamepad
./myGame.kexe
# Or inline:
KENGINE_CONTROLLER_MODE=gamepad ./gradlew :games:hextris:runDebugExecutableNativeNote: GAMEPAD mode is recommended on macOS to avoid duplicate controller detection issues with certain controllers (e.g., Nintendo SNES controllers).
Entities represent objects in the game world, from players to obstacles. The Entity class provides a base for managing position, velocity, and actions.
Example: Creating a Custom Entity
class MyEntity : Entity(width = 32, height = 32) {
override fun update() {
p.x += 1.0
}
override fun draw() {
// custom drawing logic
}
override fun cleanup() {
// cleanup resources
}
}The EventContext enables decoupled communication between components using events.
Example: Publishing and Subscribing to Events
useEventContext {
subscribe("player_died") { data: String ->
logger.info { "Player died because: $data" }
}
publish("player_died", "Fell off a cliff")
}The TiledMapLoader is a utility for loading and rendering maps in the Tiled map format. Currently, it only supports the .tmj (Tiled Map JSON) and .tsj (Tiled Tileset JSON) file formats. Maps and tilesets in the .tmx and .tsx formats are not supported.
- Loads Tiled maps (
.tmj) and external tilesets (.tsj). - Supports multiple tiled and object layers.
- Supports animated tiles and tile flipping/rotations.
- Scrollable maps with customizable controls for navigation.
- Render time for a 4-layer map with animations & rotations is ~5-7ms/render. Goal is <1ms.
Here is an example of loading and rendering a map:
val tiledMap = TiledMapLoader()
.loadMap("src/nativeTest/resources/ninjaturdle/lungs_25.tmj")
object : Game {
override fun update() {
tiledMap.update() // update animated tiles
}
override fun draw() {
tiledMap.draw() // render all layers of the map
// or draw layers by name
tileMap.draw("bg")
tileMap.draw("main")
// draw player/enemies
tileMap.draw("fg")
}
override fun cleanup() {
}
}Inspired from React Hooks
useState is a utility for managing state in your Kengine applications. It allows you to track and update values while notifying any subscribed listeners about changes. This state management mechanism is designed for lightweight use cases and integrates seamlessly with the Context system for broader application state management.
Creating and Using a State Variable
val count = useState(0)
val callback = { newValue: Int ->
println("Count changed to $newValue")
}
count.subscribe(callback)
count.set(1) // Output: Count changed to 1
count.unsubscribe(callback)
count.set(2) // No outputThe Context class in your framework serves as a foundational building block for managing scoped, singleton-like components in your application. Inspired by React’s Context API, it provides a flexible and extensible way to share functionality or state across different parts of your application without tightly coupling them.
class SimpleContext : Context() {
var count: Int = 1
}
val simpleContext = SimpleContext()
ContextRegistry.register(simpleContext)
useContext<SimpleContext> {
expectThat(count).isEqualTo(1)
count = 5
expectThat(count).isEqualTo(5)
}Context can be integrated with State to efficiently share and manage state across multiple classes.
class StatefulContext : Context() {
val count = useState(0)
}
val statefulContext = StatefulContext()
ContextRegistry.register(statefulContext)
useContext<StatefulContext> {
expectThat(count.get()).isEqualTo(0)
count.set(42)
expectThat(count.get()).isEqualTo(42)
var countUpdated = false
var countReceived = 0
count.subscribe {
countUpdated = true
countReceived = it
}
count.set(64)
expectThat(countUpdated).isTrue()
expectThat(count.get()).isEqualTo(64)
expectThat(countReceived).isEqualTo(64)
}useEffect is a utility that allows you to manage side effects in response to changes in state variables. It subscribes to the provided state dependencies and automatically triggers the effect whenever any of the dependencies change. The effect can also include a cleanup mechanism, which is executed when dependencies change or when the effect is removed.
Simple Side Effect
In this example, useEffect is used to log a message whenever the count state changes:
val count = useState(0)
useEffect({
println("The count has changed: ${count.get()}")
}, count)
count.set(1) // Logs: "The count has changed: 1"
count.set(2) // Logs: "The count has changed: 2"useMemo is a utility function for caching expensive computations based on dependencies. It ensures that a computed value is only recalculated when one of its dependencies changes.
val count = useState(0)
var computedValue = useMemo({ count.get() * 2 }, count)
expectThat(computedValue.get()).isEqualTo(0)
count.set(2) // trigger update
computedValue = useMemo({ count.get() * 2 }, count) // retrieve updated value
expectThat(computedValue.get()).isEqualTo(4)The useReducer hook is another great addition to the state management toolbox, especially for handling complex state logic. It provides a predictable way to update state by defining actions and a reducer function.
This example demonstrates using useReducer with simple String actions:
val (count, dispatch) = useReducer(0) { state: Int, action: String ->
when (action) {
"increment" -> state + 1
"decrement" -> state - 1
else -> state
}
}
expectThat(count.get()).isEqualTo(0)
dispatch("increment")
expectThat(count.get()).isEqualTo(1)
dispatch("decrement")
expectThat(count.get()).isEqualTo(0)Here’s a more robust example, showcasing useReducer with object-based actions:
data class User(val name: String, val age: Int)
abstract class UserAction
data class UpdateName(val name: String) : UserAction()
data class IncrementAge(val by: Int) : UserAction()
val initialUser = User("John", 25)
val (user, dispatch) = useReducer(initialUser) { state: User, action: UserAction ->
when (action) {
is UpdateName -> state.copy(name = action.name)
is IncrementAge -> state.copy(age = state.age + action.by)
else -> throw IllegalStateException()
}
}
expectThat(user.get().name).isEqualTo("John")
expectThat(user.get().age).isEqualTo(25)
dispatch(UpdateName("Jane"))
expectThat(user.get().name).isEqualTo("Jane")
dispatch(IncrementAge(5))
expectThat(user.get().age).isEqualTo(30)The Logger provides utility functions for debugging and monitoring game state.
Example:
logger.info { "Game started!" }
logger.error { "An error occurred." }
logger.error(e) { "An error occurred." }Actions provide a way to script entity behavior over time, such as movements or animations.
Example: Moving an Entity
useActionContext {
moveTo(entity, Vec2(200.0, 300.0), speed = 100.0) {
logger.info { "Entity reached its destination!" }
}
}Kengine includes math utilities such as Vec2 and Rect for vector and rectangle operations.
Example: Using Vectors
val position = Vec2(10.0, 20.0)
val direction = Vec2(1.0, 0.0)
val newPosition = position + direction * 5.0
logger.info { "New Position: $newPosition" }kengine/
├── kengine/ // core engine (graphics, input, entity, UI, maps, particles, math)
├── kengine-reactive/ // hooks system (useState, useEffect, useContext, useMemo, useReducer)
├── kengine-test/ // fluent assertion testing framework
├── kengine-network/ // networking via SDL3_net (TCP/UDP)
├── kengine-sound/ // audio synthesis/playback via SDL3_mixer
├── kengine-physics/ // 2D physics via Chipmunk bindings
├── packaging/ // icons and packaging resources
├── sdl3/ // SDL3 submodules and build script
├── buildSrc/ // Gradle plugins (PlatformConfig, assets, SDL dylibs, packaging)
└── games/
├── antfarm/ // ant colony simulation
├── boxxle/ // boxxle - clone of the Gameboy classic
├── helloworld/ // a simple example, a good starting point
├── hextris/ // hexagonal tetris variant
├── image-shuffle/ // image tile shuffle game
├── osc3x-synth/ // audio synthesizer with visual effects
├── osc3x-synth-v2/ // enhanced synthesizer
└── physics-demo/ // demonstration of physics engine (chipmunk)<game_name>/
├── build.gradle.kts
├── gradle.properties
├── assets/ // game assets (shared across platforms)
│ ├── sprites/ // images and sprite sheets
│ └── sounds/ // sound files
└── src/
├── nativeMain/
│ └── kotlin/ // game-specific code
└── nativeTest/ // unit and integration tests
└── kotlin/ // game-specific test codeInstall OpenJDK 17.0+
Install dependencies via Homebrew (macOS):
brew install chipmunk sdl3 sdl3_image sdl3_ttf sdl3_mixer cmake pkg-configBuild SDL3_net from source (no Homebrew formula available):
git submodule update --init --recursive
bash sdl3/build_sdl.shSee SDL3 Installation Guide for more details.
Build the project:
./gradlew clean buildRun specific tests:
./gradlew nativeTest --tests "com.kengine.ui.DrawerIT.drawer component test"./gradlew nativeTest --tests "*IT"Kengine includes a packaging plugin that creates platform-native distributable bundles for any game.
Each game applies the kengine.packaging Gradle plugin and gets platform-specific tasks automatically.
Creates a self-contained .app with bundled dylibs, assets, icon, and Info.plist.
The app can be double-clicked from Finder or distributed as-is.
./gradlew :games:helloworld:packageMac
# Output: games/helloworld/build/dist/Helloworld.appThe .app bundle structure:
Helloworld.app/
Contents/
MacOS/helloworld # executable
Resources/
assets/ # sprites, fonts, sounds, maps
kengine.icns # app icon
Frameworks/
libSDL3.0.dylib # bundled SDL3 libraries
libSDL3_image.0.dylib
libSDL3_mixer.0.dylib
libSDL3_ttf.0.dylib
libSDL3_net.0.dylib
Info.plist # app metadata
Creates a self-contained directory with a launch script that sets LD_LIBRARY_PATH, plus a .desktop entry.
./gradlew :games:helloworld:packageLinuxTarball
# Output: games/helloworld/build/dist/helloworld-linux.tar.gzCreates a distributable directory with the executable, assets, and DLLs side-by-side.
./gradlew :games:helloworld:packageWindows
# Output: games/helloworld/build/dist/helloworld-windows/Replace packaging/kengine.icns (macOS) and packaging/kengine.png (Linux/Windows) with your own artwork.
To regenerate the .icns from a set of PNGs, use iconutil:
iconutil -c icns packaging/icon.iconset -o packaging/kengine.icns- Tween/animation system (easing curves, property animation)
- Scene management (scene transitions, scene stack)
- Physics-based particle system (pooled, with lifespan/velocity/gravity)
- TiledMapLoader
- Performance enhancements (5ms/render -> <1ms/render)
- Support TMX (XML format)
- Logger file support
- Menu system
- Add Vec2 versions of functions that take (x,y) parameters, ditto for Rect2 and (x,y,w,h)
- Redesign font handling + caching/config



