Building a Responsive Scene2D UI Screen in libGDX: A Task Guide
A task guide for building a responsive Scene2D UI screen in libGDX using a scaling viewport, a fill-parent Table, and lifecycle-aware input handling.
26 Sept 2026, 20:33 UTC

The problem: one UI, many screen sizes
When a libGDX game runs on desktop, mobile, and web, pixel-perfect UI breaks. A button placed at fixed coordinates can fall off a phone screen or cluster in a corner on a wide monitor. The useful takeaway is to separate logical layout from physical pixels: build the UI inside a Scene2D Stage that uses a scaling Viewport, anchor a root Table to the stage, and tie input processing to the screen lifecycle so listeners do not leak between screens.
Prerequisites and version assumptions
You need a libGDX core module with a runnable backend (desktop, Android, iOS, or HTML), and a Skin containing at least a texture atlas and JSON description—commonly uiskin.atlas and uiskin.json from the libGDX extensions or a custom pipeline. Because Scene2D and Skin formats evolve, verify the exact libGDX release and backend requirements for your project before copying code. The examples below assume a recent stable 1.x line where the core Scene2D classes are stable, but always check the current release notes.
Procedure: a responsive UI screen
Create the Stage and viewport in show()
A Stage combines a SpriteBatch, a Viewport, and an input multiplexer. The Viewport maps a fixed virtual resolution to the real window. ExtendViewport keeps one axis exact and stretches the other to avoid black bars; FitViewport letterboxes to preserve aspect ratio. Choose based on whether distortion or bars bother you more.
private Stage stage;
private ExtendViewport viewport;
@Override
public void show() {
SpriteBatch batch = new SpriteBatch();
viewport = new ExtendViewport(800, 480, batch);
stage = new Stage(viewport);
Gdx.input.setInputProcessor(stage);
buildUI();
}The numbers 800 and 480 are a virtual design resolution; adjust them to match your art. Creating a new SpriteBatch per screen can waste GL memory, so consider sharing one batch across screens.
Anchor a root Table with setFillParent(true)
A Table is a grid-like container. Calling setFillParent(true) makes it match the Stage size so cells can use expand() and fill() to distribute space responsively. Do not call pack() on a fill-parent root; pack() sizes the table to its contents, which conflicts with filling the screen.
Table root = new Table(skin);
root.setFillParent(true);
root.defaults().pad(10);
root.add(new Label("Score", skin)).expandX().left();
root.add(new TextButton("Restart", skin)).uniformX();
root.row();
root.add(new Label("Lives", skin)).expandX().left();
root.add(new TextButton("Quit", skin)).uniformX();
stage.addActor(root);Here expandX() lets the label grow horizontally, while uniformX() equalizes button widths across the row.
Attach listeners without blocking the render thread
Use ChangeListener for state changes on Button, CheckBox, or SelectBox. Keep the listener body short; queue heavy work for the game loop. For raw touch or hover events, use InputListener with touchDown, enter, or exit.
TextButton restart = new TextButton("Restart", skin);
restart.addListener(new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
// queue a screen change or restart, do not load assets here
game.setScreen(new GameScreen(game));
}
});Loading skins or textures inside changed() causes frame hitches; prefer AssetManager or an incremental loading screen.
Update and draw in render(), handle resize()
Call stage.act(delta) before stage.draw() so animations and actions progress. In resize(), pass the new pixel dimensions to the viewport and center the camera; the Stage will re-map input automatically.
@Override
public void render(float delta) {
stage.act(delta);
stage.draw();
}
@Override
public void resize(int width, int height) {
viewport.update(width, height, true);
}The true argument centers the camera so the virtual origin stays stable.
Expected checks after implementation
- Resize the desktop window from small to ultrawide; widgets stay inside the viewport and remain clickable.
- Activate each button with mouse and touch; the listener fires exactly once per click or tap.
- Switch to another
Screenand back several times; no GL errors, no duplicate listeners, and input still reaches the new stage. - If keyboard focus is used, Tab or arrow keys move focus as expected on desktop.
Recovery options when something breaks
Skin fails to load
Check asset paths, the atlas and bitmap font pairs, and that font names in uiskin.json match the generated files. Regenerating fonts without updating the JSON will break lookups.
Layout looks wrong
Call root.setDebug(true) to draw cell boundaries. Inspect expand(), fill(), and pad() constraints. Remember that a fill-parent table ignores pack().
Clicks miss widgets
Ensure no invisible Actor overlaps the button. Check that Gdx.input.setInputProcessor(stage) was not overwritten by another processor. On mobile, verify the backend delivers touch events to the Stage.
Limitations and practical verification
This approach does not solve every problem. Blocking asset loads inside render or listeners still cause hitches, so use AssetManager for large skins. Platform backends differ in DPI handling and lifecycle callbacks; a layout that works on desktop may need tuning on Android or iOS. Finally, Skin JSON and atlas formats can change between libGDX versions, so pin your dependency and regenerate assets when upgrading.
To verify the result, run the desktop launcher, resize aggressively, and click every widget. Then switch screens repeatedly while watching the log for GL resource warnings or input errors. If the UI survives that cycle, the screen is likely robust enough for further feature work.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.