Using Scene2D for LibGDX UI Without Breaking Your Render Loop
Stop drawing buttons by hand. LibGDX Scene2D gives you a Stage and Actors for UI, but you still need to manage where it fits in your render loop.
22 Jul 2025, 10:33 UTC

Manual UI in LibGDX Quickly Becomes a Maintenance Problem
If you are building menus, buttons, or health bars with raw SpriteBatch, you are probably writing the same code over and over: track click bounds, handle hover states, sort drawing order, and update positions every frame. The immediate takeaway is to use LibGDX Scene2D for user-interface elements while keeping your gameplay rendering in SpriteBatch. Scene2D gives you a Stage and Actor hierarchy so the framework handles input dispatching and rendering order for you.
What Scene2D Actually Provides
Scene2D is LibGDX's scene graph for 2D UI. A Stage is a container that manages a group of Actor objects. An Actor is any visual element with position, size, and input handling - buttons, labels, images, or containers. Instead of calling spriteBatch.draw() for every button, you add actors to the stage and call stage.act(delta) followed by stage.draw(). The stage uses its own internal SpriteBatch, so you do not create one manually for UI.
Important: Scene2D is not a replacement for gameplay rendering. Use it for HUD, menus, and overlays. Keep your world entities in a separate SpriteBatch call so you can control rendering layers and avoid forcing the UI batch to flush every time a game object changes texture.
Worked Example: A Pause Button and Menu
This example assumes a desktop LibGDX project using the standard ApplicationAdapter lifecycle. Run it in your game's core module. No special permissions are required beyond normal desktop or Android app execution.
import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.TextButton;
public class MyGame extends ApplicationAdapter implements InputProcessor {
private SpriteBatch gameBatch;
private Stage uiStage;
private Skin skin;
@Override
public void create() {
gameBatch = new SpriteBatch();
uiStage = new Stage();
skin = new Skin(Gdx.files.internal("uiskin.json"));
TextButton pauseButton = new TextButton("Pause", skin);
pauseButton.setPosition(20, Gdx.graphics.getHeight() - 50);
pauseButton.addListener(event -> {
if (event instanceof com.badlogic.gdx.scenes.scene2d.InputEvent) {
com.badlogic.gdx.scenes.scene2d.InputEvent inputEvent =
(com.badlogic.gdx.scenes.scene2d.InputEvent) event;
if (inputEvent.getType() == com.badlogic.gdx.scenes.scene2d.InputEvent.Type.touchDown) {
Gdx.app.log("UI", "Pause pressed");
}
}
return false;
});
uiStage.addActor(pauseButton);
Gdx.input.setInputProcessor(uiStage);
}
@Override
public void render(float delta) {
Gdx.gl.glClearColor(0.1f, 0.1f, 0.1f, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
gameBatch.begin();
// Draw gameplay entities here
gameBatch.end();
uiStage.act(delta);
uiStage.draw();
}
@Override
public void resize(int width, int height) {
uiStage.getViewport().update(width, height, true);
}
@Override
public void dispose() {
gameBatch.dispose();
uiStage.dispose();
skin.dispose();
}
@Override public boolean keyDown(int keycode) { return false; }
@Override public boolean keyUp(int keycode) { return false; }
@Override public boolean keyTyped(char character) { return false; }
@Override public boolean touchDown(int screenX, int screenY, int pointer, int button) { return false; }
@Override public boolean touchUp(int screenX, int screenY, int pointer, int button) { return false; }
@Override public boolean touchDragged(int screenX, int screenY, int pointer) { return false; }
@Override public boolean mouseMoved(int screenX, int screenY) { return false; }
@Override public boolean scrolled(float amountX, float amountY) { return false; }
}
Where to run: In the create(), render(), and dispose() methods of your core game class. Permissions: Standard file access for the skin JSON; no elevated OS permissions. Expected check: The button appears at the top-left, responds to clicks, and does not interfere with gameplay drawing. Risk: Forgetting to call stage.dispose() leaks the Stage's internal SpriteBatch and textures. Also, calling stage.draw() inside gameBatch.begin() and end() will break rendering because the stage manages its own batch.
Trade-Off: Scene2D Convenience vs. Direct Control
Scene2D adds a small overhead because it maintains an actor tree and processes input for every registered actor. For a simple game with one pause button, that cost is negligible. For a particle-heavy game with thousands of short-lived visual effects, do not wrap every effect in an Actor; keep those in SpriteBatch and reserve Scene2D for persistent UI. Another practical limit is theming: Scene2D expects a Skin with a JSON definition and texture atlas. Building that skin takes more upfront work than drawing a colored rectangle.
Verify the Result
- Launch the desktop target and confirm the button renders without a crash.
- Click the button and check the console for the log message.
- Resize the window; the button should stay positioned according to the stage viewport.
- Profile the render loop if you add many actors; if frame time grows, consider whether those elements should be plain
SpriteBatchdraws instead.
Scene2D works best when you treat it as a UI layer, not a game engine replacement. Add your HUD actors to the stage, call act() and draw() after your gameplay batch ends, and dispose of the stage when the screen is disposed.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.