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, blur), outline = TextOutline(color, width)) draws a 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. The shadow is hard unless blur softens its edge across that many dp either side; with no offset it is a glow. With the built-in fonts and any font from UiFonts.load, each glyph draws its fill, outline and shadow in one quad from the font's effect field, a coarse distance field that reaches half an em past the ink: a thick outline stays round with no gaps, and the text costs one draw a glyph. Decorated text draws from the distance field at every size, so its weight does not jump at the 24-pixel limit. A font without an effect field, such as UiFonts.bitmap(), stamps the text in the shadow's colour, then eight times around it in the outline's, then once more. 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.

Draw crisp text

Text sizes its glyphs in device pixels: sp times the display density and any canvas scale, rounded to a whole pixel. So 12 sp on a 150% display is 18 physical pixels, not a stretched 12. Which path draws the text depends on that size.

Text Drawn from
24 device pixels or smaller, at rest or scrolling by whole pixels Glyphs rasterized from the font's outlines at exactly that size, drawn 1:1 on the pixel grid
24 device pixels or smaller, translating by fractions of a pixel Those same exact-size glyphs shifted smoothly across subpixel offsets, easing back onto whole pixels when motion stops
Larger than 24 pixels The font's distance-field atlas
Under Modifier.scale, an animated TextStyle.scale, or an animated em size The distance-field atlas for the whole animation, including as it passes through 1.0x or crosses 24 pixels
In UiFonts.bitmap() Its own pixel-art atlas, at every size

The exact-size glyphs put a one-pixel stem on one solid pixel column, the x-height and cap height on whole pixels, and the crossbar of an e or the bar of an H, A or F on one solid pixel row. Each glyph comes in four subpixel variants, and a glyph is drawn as the variant nearest its pen's fraction of a pixel, with its stem still on the grid, so the gaps in a run follow layout instead of jumping by up to a pixel between letters. The default Roboto family and any font from UiFonts.load have them. Each size is rasterized the first time it is drawn, in about ten milliseconds, and kept.

How coverage becomes colour

Both paths give each pixel a coverage: the share of the pixel the glyph's outline covers. Exact-size glyphs store it; the distance field gives it as a one-pixel ramp across the outline. One rule, the text coverage rule, turns that coverage into the alpha the pixel is blended with. It is the same on both paths, on Vulkan, on WebGPU and in the CPU raster the snapshot tests use, so text does not change weight when it crosses the 24-pixel limit or starts to scale.

The rule is Skia's mask gamma with the constants Chromium uses for grayscale text on Linux:

  1. Take the text colour's luma: its channels raised to the power 1.2, weighted 0.2126, 0.7152 and 0.0722, and the sum raised back by 1 / 1.2.
  2. Assume the ground is the opposite of the text: ground luma = 1 − text luma. The shader cannot see what is behind the text.
  3. Boost the coverage c with a contrast of 0.2 times the ground's linear luma: c + (1 − c) × k × c. Dark text on a light ground gains a little, and light text on a dark ground gains nothing.
  4. Mix text and ground by that coverage in light linearised with gamma 1.2. Then take the alpha that gives the same colour when the GPU blends in the target's own encoding.
Constant Value Where
Gamma 1.2 TEXT_GAMMA in AslUiShaders.UiGlyphShader; UiRasterizer.textCoverageAlpha copies it
Contrast 0.2 TEXT_CONTRAST, likewise
Luma weights 0.2126, 0.7152, 0.0722 Rec. 709

At half coverage, near-black text on white draws with alpha 0.50 and near-white text on near-black with 0.55. The exact-size glyphs used to blend with gamma 1.8 and a contrast of 1 in both polarities, which gave 0.49 and 0.80, so light text looked bolder than dark. The distance field used pow(c, 1 / 1.45), which gave 0.62 for both.

TextPolarityWeightTest holds the rule to Chromium. Chromium draws the same label in the same Roboto face, dark on light and light on dark, at every size from 9 to 48 px. The references are written by tools/fonts-tooling/chromium_polarity_reference.cjs. Awake's ink is within 6% of Chromium's at every size in both polarities, 0.95 to 1.05 of it. The light-on-dark to dark-on-light ink ratio is within 6% of Chromium's own. Ink means the summed darkness of the text's pixels, measured toward the text colour. From 24 px pixel glyphs to 25 px distance field, the ink per em changes by under 2% and stem darkness by under 3.5%. Before the rule, light-on-dark small text held 1.16 to 1.29 times Chromium's ink, and crossing the limit moved ink by 9 to 10% in each polarity.

TextProofSpecimenTest holds a full 520 × 714 dp proof page to Chromium in both polarities at 1x and 1.5x, alongside four 8-bit grey baselines: the 9 to 48 px size ramp with kerning on, Roboto weights 300 to 900, a runtime-loaded TTF (Roboto-Medium.ttf) at pixel-glyph and distance-field sizes, a wrapped paragraph, and a HUD readout. Every band stays 0.95 to 1.05 of Chromium's ink (0.949 to 1.045), with the light-on-dark to dark-on-light ratio within 5.3% of Chromium's.

The rule does no stem darkening. Chromium does none, and the sizes it was measured at are within the tolerance without it.

Text colour and custom shaders

  • The colour picks the curve. The rule reads only the text colour, never the ground. Dark text gets a slightly heavier edge and light text a slightly lighter one than plain coverage would give. Text on a ground close to its own luma, such as grey on grey, is drawn as if the ground were the opposite of the text, as Chromium draws it. A saturated colour counts by its luma: pure red's is about 0.28, so red text is treated as dark text, wherever it is drawn.
  • Mid-grey text has a luma of 0.5 and its assumed ground is itself, so it draws with the boosted coverage as it is.
  • The colour's alpha multiplies the rule's alpha afterwards. Faded text keeps the shape of its edges and only gets lighter.
  • Layers and opacity. The rule applies when a glyph is drawn, including into a graphicsLayer. A layer composited later gives the same pixels as drawing straight onto the ground.
  • A shader document (shader_effect) draws as a pass of its own. It never changes how text is drawn: text it covers, or that covers it, has already been drawn with the rule. An image pass that post-processes the frame sees text after the rule as well.
  • A glyph shader of your own. A renderer built with its own UiShaderSet.glyph must apply the rule itself, or its text will not match the CPU raster or the other backend. Start from UiGlyphShader in awake:asset:shaders. Its fontInfo uniform says which atlases take the rule: x = 1 for a distance field, and x = 0 with z = 1 for exact-size coverage. A bitmap font such as UiFonts.bitmap() (z = 0) and colour glyphs (x = 2) draw their alpha unchanged.

The reference small text is held to

The weight of small text is checked against references we control, not against Awake's own earlier output. Both polarities are held to Chromium, as above. The shapes of small dark-on-light text are also held to FreeType. tools/fonts-tooling/reference_render.py renders the same labels with FreeType, pinned to 2.13.2 through freetype-py 2.5.1 in requirements-reference.txt, with these settings:

Setting Value Why
Hinting Light (FT_LOAD_TARGET_LIGHT) Snaps horizontal edges and the x-height to the grid, leaves shapes and advances alone
Stem darkening On Keeps thin strokes from fading once blending stops thickening them
Anti-aliasing 8-bit grayscale coverage The quantity Awake's exact-size glyphs hold too
Blending Gamma 1.8 The gamma FreeType documents for light hinting with stem darkening, and the classic text gamma

The PNGs live in awake/compose/ui-testing/src/desktopTest/resources/text-reference/, and TextReferenceMetricsTest compares Awake's rendering with them per case: stem width, ink mass, the darkness of curves and diagonals against stems, the share of faint ink pixels, and the mean pixel difference. It asserts the dark-on-light cases and only reports the light-on-dark ones. The reference composites light text with gamma 1.8, which gives it about 1.4 times the ink of dark text, where Chromium gives about 1.1 times. The PNGs change only when someone deliberately reruns the generator:

python3 -m pip install -r tools/fonts-tooling/requirements-reference.txt
python3 tools/fonts-tooling/reference_render.py

To see any component baseline beside FreeType's glyphs, write glyph packs with reference_render.py --glyph-pack <dir> and run the snapshot tests with AWAKE_TEXT_REFERENCE_GLYPHS=<dir>; each baseline is also written, in FreeType's glyphs, to build/reports/text-reference/.

To move the limit, or to draw a font from its distance field at every size, wrap it once and keep it:

Kotlin
val headings = UiFonts.default().withPixelGlyphMaxSize(16) // exact-size glyphs up to 16 px
val uniform = UiFonts.default().withPixelGlyphMaxSize(0)   // the distance field at every size
Text("Inventory", style = TextStyle(font = uniform))

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