Skip to content

UI with AwakeKt Compose

AwakeKt Compose is AwakeKt's own retained UI engine with a Compose-shaped Kotlin API. Use it for menus, overlays and tools that share the frame with your game.

module: awake:compose:foundation Vulkan WebGPU Desktop · Android · iOS · Web

Add com.awakekt.awake.compose:foundation (catalog alias libs.awake.compose.foundation) to commonMain. For headless UI tests, add com.awakekt.awake.compose:ui-testing to your test source set. For a HUD saved inside a scene, see Game UI.

It is not Jetpack Compose

The function and modifier names follow Compose, but the engine is different. Do not copy Jetpack Compose code without checking it against these rules.

  • A UI function takes a context(_: Composer) parameter. There is no @Composable annotation and no compiler plugin.
  • The whole tree is rebuilt every frame in one synchronous pass. Nothing is skipped.
  • remember { … } keeps a value from one frame to the next. There is no rememberSaveable, LaunchedEffect, DisposableEffect or ViewModel.
  • Input is shared with the game. Each frame reports what the UI claimed, so gameplay can ignore a click that landed on a button.

Lay out a component

Row, Column and Box place their children. Modifiers set size, padding, background and input.

Kotlin
context(_: Composer)
fun PlayerCard(name: String) {
    Row(
        modifier = Modifier.background(Color(0.1f, 0.1f, 0.12f, 1f)).padding(16.dp),
        horizontalArrangement = Arrangement.spacedByHorizontal(12.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Box(Modifier.size(40.dp).background(Color(0.3f, 0.5f, 0.9f, 1f)).testTag("avatar"))
        Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
            Text(name, Modifier.testTag("title"))
            Text("Level 3")
        }
    }
}

Row spacing has its own function

Arrangement.spacedBy(…) returns a vertical arrangement for a Column. For a Row, use Arrangement.spacedByHorizontal(…).

Keep state across frames

Hold a value with remember { mutableStateOf(…) } in the lowest node that owns it. Pass the value down and take events back up, so a component such as Counter has no hidden state.

Kotlin
context(_: Composer)
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }
    Counter(count, onIncrement = { count++ }, onReset = { count = 0 })
}

context(_: Composer)
fun Counter(count: Int, onIncrement: () -> Unit, onReset: () -> Unit) {
    Row(horizontalArrangement = Arrangement.spacedByHorizontal(8.dp)) {
        Text("Count: $count")
        Box(Modifier.testTag("increment").clickable(onClick = onIncrement).padding(8.dp)) { Text("+") }
        if (count > 0) {
            Box(Modifier.testTag("reset").clickable(onClick = onReset).padding(8.dp)) { Text("Reset") }
        }
    }
}

Remembered values are matched by call order

A remember belongs to its enclosing node and is found by call order. Never call remember after a list whose length changes in the same node, and never inside an if that flips. Give each repeated item its own node and a key(…).

Show an image

Image draws an ImageBitmap: RGBA8 pixels, rows top to bottom. contentScale is Fit (the default), Crop, FillBounds or Inside. A contentDescription is what assistive technology reads; pass null for a decorative image.

Kotlin
/** 2 x 2 RGBA8, rows top to bottom: white, black, then black, white. */
private val checker = ImageBitmap(
    width = 2,
    height = 2,
    pixels = byteArrayOf(
        -1, -1, -1, -1, 0, 0, 0, -1,
        0, 0, 0, -1, -1, -1, -1, -1,
    ),
)

context(_: Composer)
fun Checker() {
    Image(checker, contentDescription = "Checker", modifier = Modifier.size(64.dp), contentScale = ContentScale.FillBounds)
}

tint multiplies every pixel's colour; white, the default, leaves it as it is. filterQuality FilterQuality.None samples the nearest pixel, so pixel art stays crisp when it's scaled; FilterQuality.Low, the default, blends neighbouring pixels. To draw part of a bitmap, such as one icon of a sheet, call drawImage(bitmap, …, srcX, srcY, srcWidth, srcHeight) from a draw modifier.

Frames and gauges

Modifier.background(ImageFill(…)) fills a node of any size from one image cut into nine. The four slice insets, in the image's pixels, mark the corners, which keep their size at one dp per pixel, or per pixelsPerDp pixels for art drawn at 2x or 3x. The edges stretch along their length, or repeat at that size with repeatEdges; the centre stretches, repeats both ways with repeatCenter, or is left out with hollow. Slice only the left and right for a three-part strip such as a gauge, and pass srcX, srcY, srcWidth and srcHeight to cut the frame from a sheet. A node too small for its corners shrinks them together.

Kotlin
/** A window drawn from a 48 x 48 frame: its 16-pixel corners kept, its edges and centre tiled. */
context(_: Composer)
fun FramedPanel(frame: ImageBitmap) {
    val fill = ImageFill(frame, sliceLeft = 16, sliceTop = 16, sliceRight = 16, sliceBottom = 16, repeatEdges = true, repeatCenter = true)
    Box(Modifier.size(240.dp, 120.dp).background(fill))
}

From a draw modifier, drawImageFill(fill, x, y, width, height) draws the same fill into part of the node. drawImageFillBar(fill, value, direction) draws it as a bar's fill, from the edge direction names: both end caps stay whole at every value above 0, and only the middle grows.

Gradients and styles

Modifier.background(brush, shape) fills a node with a Brush: Brush.vertical(top, bottom), Brush.horizontal(start, end), or one colour per corner with Brush.linearGradient. Inside a Style, background(brush) does the same in place of a background colour, backgroundImage(fill) draws an ImageFill over it, and dropShadow(shadow) casts a shadow in the style's shape. Scene UI's canvas_element style is the saved form of the same properties.

TextStyle(shadow = TextShadow(color, offsetX, offsetY), outline = TextOutline(color, width)) draws a hard shadow and an outline under a Text or a BasicTextField's text, in dp; both merge over an inherited style like its colour and size do. A scene's canvas_element text ignores the host's text style and looks only as its own data says.

To load a PNG or JPEG, call decodeImageBitmap(bytes). It suspends, because the browser decodes asynchronously, so decode outside the UI and show the result on a later frame. The engine uploads each bitmap to the GPU the first frame it appears and keeps it, so reuse a decoded bitmap instead of decoding the same file again.

Put UI on the screen

An app has one Compose host. Pick the form that matches your app.

A UI-only app, with no scene:

Kotlin
val game = app {
    module(composeAppModule(content = { Text("Hello from AwakeKt") }))
}

UI that belongs to one scene, drawn over it:

Kotlin
val game = app {
    scene("level") {
        cameraEntity("camera")
        ui { Text("Score 0") }
    }
}

UI that belongs to the whole app, drawn over whichever scene runs. Install sceneComposeAppModule before the scene:

Kotlin
val game = app {
    module(sceneComposeAppModule(content = { Text("Paused") }))
    scene("level") {
        cameraEntity("camera")
    }
}

composeAppModule comes from awake:engine:compose. sceneComposeAppModule and the scene's ui { } come with the scene modules.

Only one host

Installing a second composeAppModule throws. Installing sceneComposeAppModule after a scene that already declares ui { } also throws.

Test UI without a window

composeFrame builds, measures, places and paints one frame, then lets you find nodes by test tag and check their bounds. No GPU or window is needed.

Kotlin
val frame = composeFrame(width = 320, height = 200) { PlayerCard("Harbor Town") }

frame.onNodeWithTag("avatar").assertBoundsInRoot(ComposeTestBounds(left = 16, top = 16, width = 40, height = 40))

A frame is a snapshot. To test input, use composeTestSession: it keeps the UI between frames, and click(tag) presses and releases on a node, then returns the next frame.

Kotlin
val session = composeTestSession(width = 320, height = 120) { CounterScreen() }
session.frame().onAllNodes(hasTestTag("reset")).assertCountEquals(0)

session.click("increment").onNodeWithTag("reset").assertExists()
session.click("reset").onAllNodes(hasTestTag("reset")).assertCountEquals(0)

A session also has hover(tag), clickAt(x, y) and pressKey(key). frame.printToString() prints the semantics tree and a count of what was painted, which is the quickest way to see why a check failed.

How it works

Module What it holds
awake:compose:runtime Composer, remember, CompositionLocal.
awake:compose:ui Layout nodes, Modifier, drawing, input, focus, semantics, ImageBitmap.
awake:compose:foundation Row, Column, Box, Text, Image, BasicTextField, lazy lists, clickable, scrolling and gestures.
awake:compose:ui-testing composeFrame, composeTestSession, semantics matchers, a CPU rasterizer.
awake:engine:compose composeAppModule, which connects one Compose host to an app.

Each frame, the host turns the app's input into a UI frame, lays out and paints the tree, and hands the draw primitives to the renderer. It also reports what the UI claimed from the input: whether a pointer is captured, a text field has focus, or a scroll was consumed. In a scene, SceneAppLifecycleRuntime.uiOwnership holds that report, and the camera and player systems read it.

See also