Your first screen - wildware-uk/composegl GitHub Wiki
Your first screen
By the end of this page you will have a window with a panel in it, a bit of text, and a button that counts how many times you clicked it.
It is about eighty lines of code, and most of them are opening the window.
This page assumes you have written Compose before β
@Composable,remember,mutableStateOf, recomposition. If any of that is new, half an hour with Thinking in Compose and State and Jetpack Compose will cover everything this page leans on.
What ComposeGL actually is
The same runtime you already know β androidx.compose.runtime, unchanged β with
everything above it replaced. Our own applier, layout, widgets, renderer and input,
built for games.
So Text("Score: $score") behaves exactly as it does in Compose UI. What is
different is where it ends up: drawn with OpenGL, through your engine, inside your
game loop. No separate window, no web view, no second thread, no Android
dependency.
What you will not find, because none of it came with us: dp, Material,
Modifier.Node, LaunchedEffect's Android plumbing, or any of androidx.compose.ui.
The names that survive mean what they always meant.
1. Add it to your build
ComposeGL runs on top of Compose's runtime, so you need the Compose compiler plugin as well as the library:
// build.gradle.kts
plugins {
kotlin("jvm") version "2.4.20"
id("org.jetbrains.kotlin.plugin.compose") version "2.4.20"
}
repositories {
mavenCentral()
google() // the Compose runtime reaches for androidx, which lives here and not on Central
}
dependencies {
implementation("dev.wildware.composegl:composegl-ui:0.6.0")
// One backend. This one draws through LibGDX.
implementation("dev.wildware.composegl:composegl-gdx:0.6.0")
// LibGDX's desktop window, and the native code it and its FreeType fonts need.
implementation("com.badlogicgames.gdx:gdx-backend-lwjgl3:1.14.2")
runtimeOnly("com.badlogicgames.gdx:gdx-platform:1.14.2:natives-desktop")
runtimeOnly("com.badlogicgames.gdx:gdx-freetype-platform:1.14.2:natives-desktop")
}
Backends. A backend is the piece that turns "draw a rounded box here" into actual OpenGL calls. There are four:
composegl-gdx(LibGDX),composegl-lwjgl3(raw OpenGL, desktop only),composegl-webgl(a browser tab) andcomposegl-korge(KorGE). Pick the one your game already uses. Nothing in your interface code changes if you swap. See Backends.
2. A frame, in one line
Make one of these when your game starts:
val ui = UiRenderer(host, canvas)
β¦and call it once a frame, after your game has drawn its own world:
ui.render(viewport, System.nanoTime())
That is the whole loop. It returns false when nothing on screen changed, so a
menu that is just sitting there costs you almost nothing.
Input goes in a hook rather than in the loop, because working out what the mouse is over means knowing where everything is β so it cannot happen until after the layout:
ui.onLaidOut = { millis -> input.frame(millis) }
What it is doing
Four things, and you can call them yourself if you want to put something between them:
val draw = DrawPass(canvas) // once
host.frame(System.nanoTime()) // 1. did anything change?
MeasurePass().run(host.root, viewport) // 2. how big is everything, and where?
canvas.begin(viewport)
draw.draw(host.root, viewport) // 3. draw what the screen can show
canvas.end() // 4. hand it to the GPU
Handing draw the viewport is what lets it skip what is off the screen: a widget
whose drawing cannot reach the viewport, or a clip it is inside β the rows of a long
list below the fold β is not drawn at all. draw(host.root) with no viewport still
skips what a clip cuts off, but draws everything else wherever it is.
An analogy: frame is asking "has anybody changed their mind?", measure is laying the furniture out in the room, and draw is taking the photograph.
Five, once a pad or the arrow keys can drive your screen: focus has to be kept
pointing at something that is still there. Hand the manager over and the renderer
does it, in the right place β after the layout, before onLaidOut:
ui.focus = focus
The first two of those steps, plus the focus refresh, are also one call β
host.settle(viewport, focus, nanos) β which is what a test that never draws
uses. See Testing.
settle, and so the renderer, skips step 2 on a frame where nothing changed: no
state written, no input, no resize under way, and the same viewport as last time.
The furniture is already where it goes, so nobody lays it out again. Written out by
hand as above, the pass runs every frame, but it only measures what changed since
the last one β on a still frame, nothing β so that is cheap too.
If your game draws into the same canvas as the interface β a 2D game putting its board under its HUD, say β you do not need to write it out. Hand the drawing over instead, and it happens inside the canvas's frame, under everything:
ui.drawBehind = { canvas -> board.draw(canvas) }
A 3D game normally has nothing to put there: its world goes to OpenGL directly, before the canvas's frame is even open.
Keep the DrawPass if you write it out yourself β it holds the canvas and a few
numbers it reuses, so making a new one every frame is waste. The MeasurePass has to be new
each time: the object itself is how each node knows which pass it was measured in.
3. The whole program
Two files. First, the interface β this one is pure ComposeGL and knows nothing about LibGDX, windows, or OpenGL:
// Hello.kt
import androidx.compose.runtime.*
import dev.wildware.composegl.ui.layout.*
import dev.wildware.composegl.ui.modifier.*
import dev.wildware.composegl.ui.widget.*
@Composable
fun Hello() {
var clicks by remember { mutableStateOf(0) }
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Centre) {
Panel(Modifier.width(280f)) {
Column(verticalArrangement = Arrangement.spacedBy(12f)) {
Text("HELLO")
Text("Clicked $clicks times")
Button("CLICK ME", onClick = { clicks++ })
}
}
}
}
That is the part you will spend your time in. clicks++ is the only thing that
ever "updates the label".
Second, the plumbing β the window, the fonts, and the four lines from above:
// Main.kt
import com.badlogic.gdx.*
import com.badlogic.gdx.backends.lwjgl3.*
import com.badlogic.gdx.graphics.GL20
import com.badlogic.gdx.graphics.g2d.SpriteBatch
import dev.wildware.composegl.gdx.*
import dev.wildware.composegl.ui.geometry.Size
import dev.wildware.composegl.ui.host.UiHost
import dev.wildware.composegl.ui.host.UiRenderer
import dev.wildware.composegl.ui.input.PointerRouter
import dev.wildware.composegl.ui.layout.*
import dev.wildware.composegl.ui.widget.ProvideFonts
class HelloGame : ApplicationAdapter() {
private lateinit var fonts: GdxFonts
private lateinit var sprites: SpriteBatch
private lateinit var canvas: GdxCanvas
private lateinit var host: UiHost
private lateinit var ui: UiRenderer
private var viewport = Viewport.oneToOne(Size(1280f, 720f))
override fun create() {
// Fonts are the one thing the toolkit cannot invent. "default" is the
// name the default skin asks for, and 16 is the size it asks for.
fonts = GdxFonts()
fonts.registerTrueType("default", Gdx.files.internal("DejaVuSans.ttf"), listOf(13, 16, 20))
// The canvas draws text from the fonts' pages, so panels and labels share one texture.
sprites = SpriteBatch()
canvas = GdxCanvas(sprites, fonts)
host = UiHost()
host.setContent { ProvideFonts(fonts) { Hello() } }
// Mouse in. The router works out which widget is under the pointer and
// what counts as a click; you do not.
val router = PointerRouter(host.root)
Gdx.input.inputProcessor = GdxPointerInput(router, { viewport })
// Last, because it needs the host and the canvas.
ui = UiRenderer(host, canvas)
}
override fun render() {
viewport = Viewport(
design = Size(1280f, 720f),
physical = Size(Gdx.graphics.backBufferWidth.toFloat(), Gdx.graphics.backBufferHeight.toFloat()),
policy = ScalePolicy.Fit,
)
Gdx.gl.glClearColor(0.03f, 0.04f, 0.05f, 1f)
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
ui.render(viewport, System.nanoTime())
}
override fun dispose() {
host.dispose()
canvas.dispose()
sprites.dispose()
fonts.dispose()
}
}
fun main() {
Lwjgl3Application(HelloGame(), Lwjgl3ApplicationConfiguration().apply {
setTitle("Hello")
setWindowedMode(1280, 720)
})
}
Run it. You get a dark window with a panel in the middle and a button that counts:

You did not write a skin, and it still looks like something. That is Skin.Default,
the neutral dark skin that ships with the toolkit. Replacing it is its own page.
4. What the viewport is for
You wrote 1280x720 once, and then used Modifier.width(280f) as if the window
were always that size. It is not β the player can resize it, and a phone is a
different shape entirely.
Viewport is the fix. You design against one fixed size, and it scales:
Viewport(
design = Size(1280f, 720f), // the size you pretend you have
physical = Size(1920f, 1080f), // the size you actually have
policy = ScalePolicy.Fit, // scale up, keep the shape, letterbox the rest
)
So 280f means "280 wide on a 1280-wide screen", everywhere, forever. No density
buckets, no dp, no per-device layout.
It works in both directions. A window smaller than the design β a 1280-wide design on a 360-wide phone β is fine: text is drawn again at the screen's own pixel size, so hyphens and thin strokes do not disappear on the way down.
5. Arranging things
Three layouts do almost everything:
Column { Text("one"); Text("two") } // stacked downwards
Row { Text("one"); Text("two") } // side by side
Box { Text("on top of each other") } // piled up



Spacing and alignment are arguments, not spacer widgets:
Row(
horizontalArrangement = Arrangement.SpaceBetween, // push them to the edges
verticalAlignment = VerticalAlignment.Centre,
) {
Text("AMMO")
Text("148")
}
And Modifier is the chain of things done to a widget β size, padding, where it
sits, what happens when you click it:
Panel(
Modifier
.align(Alignment.BottomStart) // where in the parent
.padding(left = 28f, bottom = 28f)
.width(280f)
) { β¦ }

Order matters where you would expect it to. padding(8f).background(Colour.Blue)
paints the blue inside the padding; background(Colour.Blue).padding(8f) paints it
across the whole widget and puts the padding inside.
6. State is the state you already know
remember, mutableStateOf, derivedStateOf, snapshotFlow β all of it is the
real androidx.compose.runtime, behaving exactly as it does everywhere else.
The one thing worth saying, because games get it wrong: put the state on the object your game already owns, rather than copying values into the interface each frame.
class Player {
var health by mutableStateOf(100)
var ammo by mutableStateOf(148)
}
@Composable
fun Hud(player: Player) {
Text("HP ${player.health}")
}
Now player.health -= 10 from your combat code redraws that one label. Nothing
else on the screen is touched, and host.frame(...) keeps returning false on the
frames where nothing changed.
The trap is the other way round:
@Composable
fun Hud(player: Player) {
Text("HP ${player.healthThisFrame}") // β a plain field: nothing recomposes
}
Where next
- Layout β Column, Row, Box, weights, and how measuring actually works
- Skins β making it look like your game instead of like the default
- Input β keyboard, gamepad, focus, and giving the world what the interface did not want
- Game widgets β health bars, crosshairs, damage numbers, world-anchored markers
- Shaders β blur, outline, dissolve, and binding your own GLSL to a widget