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@Composableannotation 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 norememberSaveable,LaunchedEffect,DisposableEffectorViewModel.- 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.
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.
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.
/** 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.
/** 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:
UI that belongs to one scene, drawn over it:
UI that belongs to the whole app, drawn over whichever scene runs. Install
sceneComposeAppModule before the scene:
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.
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.
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
- Game UI for a HUD stored in a scene.
- shadcn components for themed buttons, fields and menus.
- Button reference.