Bismillah, dear readers!
I hope you enjoyed the long weekend and managed to recharge. Today weāll explore building a simple game using simpleāworldāgdx (my lightweight ECS for LibGDX), together with simpleābatchāgdx and LibGDX. Iāll guide you step-by-step in a beginner-friendly way, with plenty of code snippets and screenshots/GIFs I’ve collected during the coding journey.
Project Setup & Dependencies š¦
Letās start fresh. Create a new LibGDX project (you can use the official setup or your preferred tool).
I’ve used gdx-liftoff for the initial project setup, it’s a really simple and well integrated tool. Open the official repo on GitHub, go to the Releases tab and download the latest JAR-File or the build specific to your operating system.
Launch the tool and fill in the project name, package, and main class, it should look like this:

Now click on project options and select your target platform and add any extensions if you’d like. Unfortunately, my extensions are too new for this tool so we’ll have to add them manually later on. Click next.
Here, you’ll have a big list of thirdparty extensions to choose from, that’s where the real power of LibGDX lies: It’s eco-system!

Click next, and finally, you’ll be prompted to fill in some basic information about the runtime. We’ll go with the latest LibGDX release, 1.13.5 as of today, and Java 17 for compatibility. Choose the project folder according to your environment and click generate:

Wait a couple of seconds and your project is created at your previously specified path. Now you just have to open it with your favorite IDE and get coding. My advice: Use IntelliJ IDEA, it’s by far the best IDE for Java environments out there. I’ve also worked with Eclipse for nearly a decade and it may work here though it’s not as up-to-date with the features of IDEA.

After opening and loading the project in your IDE, you’ll have to first the required dependencies manually. In the core moduleās build.gradle, add the following dependencies:
implementation "com.github.ForgeLeaf:simple-world-gdx:8881aa4afa"
implementation "com.github.ForgeLeaf:simple-batch-gdx:66ff434d30"
Ensure you have the JitPack repository enabled in your root build.gradle (it’s enabled by default for the gdx-liftoff templates):
allprojects {
repositories {
maven { url "https://jitpack.io" }
// other repositoriesā¦
}
}
Why these dependencies?
Because simple-world-gdx gives us an ECS structure (Entities + Components/Nodes + Systems) that integrates cleanly with LibGDX. And simple-batch-gdx simplifies drawing with LibGDXās batch logic. Together they help you keep your game logic clean and scalable.
Now just launch the demo by double-pressing CTRL and/or executing this command: gradle :lwjgl3:run

Beautiful, let’s create our game now!
Asset Preparation š¼ļø
For this demo weāll use two free assets from OpenGameArt:
- A cannonball sprite from āCannonballā by Flixberry Entertainment, licensed CC-BY 4.0 / CC0. OpenGameArt.org
- A pixel art rotating coin sprite from āRotating Coinā by Puddin, licensed CC0. OpenGameArt.org
Download both and place them into your assets folder.

Make sure your desktop launcher (or whichever platform) picks up the assets path correctly.
The Main Game Class š§©
Below is the full code for SimpleWorld.java. After that Iāll walk you through what each part does.
package com.forgeleaf.tutorial.simpleworldgdx;
import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.files.FileHandle;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.viewport.FitViewport;
import com.badlogic.gdx.utils.viewport.Viewport;
import com.forgeleaf.simplebatch.SimpleBatch;
import com.forgeleaf.simpleworld.World;
import com.forgeleaf.tutorial.simpleworldgdx.node.AnimationNode;
import com.forgeleaf.tutorial.simpleworldgdx.node.BallNode;
import com.forgeleaf.tutorial.simpleworldgdx.node.PhysicsNode;
import com.forgeleaf.tutorial.simpleworldgdx.systems.AnimationSystem;
import com.forgeleaf.tutorial.simpleworldgdx.systems.BallSystem;
import com.forgeleaf.tutorial.simpleworldgdx.systems.PhysicsSystem;
import java.util.ArrayList;
import java.util.List;
public class SimpleWorld extends ApplicationAdapter {
private List<Texture> textures;
private SimpleBatch batch;
private Viewport viewport;
private World world;
private Texture createTexture(FileHandle fileHandle) {
Texture texture = new Texture(fileHandle);
textures.add(texture);
return texture;
}
@Override
public void create() {
textures = new ArrayList<>();
Texture coinTexture = createTexture(Gdx.files.internal("Coin.png"));
TextureRegion[] coinFrames = new TextureRegion[coinTexture.getWidth() / 16];
for (int i = 0; i < coinFrames.length; i++)
coinFrames[i] = new TextureRegion(coinTexture, i * 16, 0, 16, 16);
Animation<TextureRegion> coinAnimation = new Animation<>(0.1f, Array.with(coinFrames), Animation.PlayMode.LOOP);
Texture ballTexture = createTexture(Gdx.files.internal("Ball.png"));
TextureRegion[] ballFrames = { new TextureRegion(ballTexture) };
Animation<TextureRegion> ballAnimation = new Animation<>(1f, Array.with(ballFrames), Animation.PlayMode.LOOP);
batch = SimpleBatch.create();
viewport = new FitViewport(16, 10);
world = new World();
world.register(new AnimationSystem(world, batch, viewport));
world.register(new BallSystem(world, viewport));
world.register(new PhysicsSystem(world));
// Spawn Coins
for (int i = 0; i < MathUtils.random(20); i++) {
world.spawn((entity, world) -> {
world.attachRaw(entity, AnimationNode.class, AnimationNode::new).animation = coinAnimation;
world.attachRaw(entity, PhysicsNode.class, PhysicsNode::new).position.set(
MathUtils.random(viewport.getWorldWidth()),
MathUtils.random(viewport.getWorldHeight())
);
});
}
// Spawn Ball
world.spawn((entity, world) -> {
world.attachRaw(entity, AnimationNode.class, AnimationNode::new).animation = ballAnimation;
world.attachRaw(entity, BallNode.class, BallNode::new);
PhysicsNode physics = world.attachRaw(entity, PhysicsNode.class, PhysicsNode::new);
physics.width = physics.height = 2f;
});
}
@Override
public void render() {
world.update(Gdx.graphics.getDeltaTime());
}
@Override
public void resize(int width, int height) {
viewport.update(width, height, true);
}
@Override
public void dispose() {
batch.dispose();
world.dispose();
for (Texture texture : textures)
texture.dispose();
}
}
Explanation
- We keep a
List<Texture> texturesso we can clean them up indispose()reliably. - We load the coin sprite sheet, slice it into frames of width 16px, and create a looping
Animation<TextureRegion>. - We load the ball sprite (single image) and create a one-frame animation (so our code treats both coins and ball uniformly).
- We create the
SimpleBatchand aFitViewport(16,10)so our game world has width 16 units and height 10 units in world coordinates. - We instantiate the
Worldfrom simple-world-gdx and register three systems:AnimationSystem,BallSystem,PhysicsSystem. - We spawn a random number of coin entities, each with an
AnimationNodeset to the coin animation, and aPhysicsNodewhose position is randomly set in the viewport. - We spawn the ball entity, and attach an
AnimationNodefor the ball sprite, aBallNodefor special ball behaviour, and aPhysicsNodewhose width/height we set to 2 units. - In
render()we callworld.update(deltaTime)so that all systems run in sequence. - In
resize(), we update the viewport. Indispose(), we dispose of batch, world and the textures.

Defining Nodes (Components) š
Here are our node (component) classes that represent data attached to entities. A node represents a component, I just had to name it differently because of a package conflict in my library.
AnimationNode.java
package com.forgeleaf.tutorial.simpleworldgdx.node;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.forgeleaf.simpleworld.Node;
public class AnimationNode extends Node {
public Animation<TextureRegion> animation;
public float timeElapsed;
public AnimationNode() {
reset();
}
@Override
public void reset() {
animation = null;
timeElapsed = 0;
super.reset();
}
}
This node holds an Animation<TextureRegion> and the elapsed time so we can fetch the correct frame for drawing.
BallNode.java
package com.forgeleaf.tutorial.simpleworldgdx.node;
import com.forgeleaf.simpleworld.Node;
public class BallNode extends Node {
public BallNode() {
reset();
}
@Override
public void reset() {
super.reset();
}
}
This node doesnāt carry extra data; it simply marks an entity as the āballā. Systems filter on it to apply behaviour specific to the ball.
PhysicsNode.java
package com.forgeleaf.tutorial.simpleworldgdx.node;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import com.forgeleaf.simpleworld.Node;
public class PhysicsNode extends Node {
public Vector2 position;
public Vector2 velocity;
public float speed;
public float width;
public float height;
public PhysicsNode() {
reset();
}
@Override
public void reset() {
position = new Vector2();
velocity = new Vector2(MathUtils.random() - 0.5f, MathUtils.random() - 0.5f);
speed = MathUtils.random() * 2f;
width = 1;
height = 1;
super.reset();
}
}
This holds position, velocity, speed, width and height. We initialise velocity randomly so coins drift visually.
Don’t forget to place your nodes in a separate package as a best practice:

Writing the Systems š¼
Here are the systems that define behaviour.
AnimationSystem.java
package com.forgeleaf.tutorial.simpleworldgdx.systems;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.utils.IntSet;
import com.badlogic.gdx.utils.ScreenUtils;
import com.badlogic.gdx.utils.viewport.Viewport;
import com.forgeleaf.simplebatch.SimpleBatch;
import com.forgeleaf.simpleworld.World;
import com.forgeleaf.simpleworld.system.IteratingSystem;
import com.forgeleaf.tutorial.simpleworldgdx.node.AnimationNode;
import com.forgeleaf.tutorial.simpleworldgdx.node.PhysicsNode;
public class AnimationSystem extends IteratingSystem {
private final Viewport viewport;
private final SimpleBatch batch;
public AnimationSystem(World world, SimpleBatch batch, Viewport viewport) {
super(world, 0, world.filter().allOf(AnimationNode.class, PhysicsNode.class));
this.viewport = viewport;
this.batch = batch;
}
@Override
protected void tick(float delta) {
World world = getWorld();
IntSet.IntSetIterator iterator = getEntities().iterator();
ScreenUtils.clear(Color.WHITE);
viewport.apply(true);
batch.projection(viewport.getCamera().combined);
batch.begin();
while (iterator.hasNext) {
int entity = iterator.next();
handle(
world.get(entity, AnimationNode.class),
world.get(entity, PhysicsNode.class),
delta
);
}
batch.end();
}
private void handle(AnimationNode animation, PhysicsNode physics, float delta) {
animation.timeElapsed += delta;
batch.draw(
animation.animation.getKeyFrame(animation.timeElapsed),
physics.position.x - physics.width / 2f,
physics.position.y - physics.height / 2f,
physics.width,
physics.height
);
}
}
The system selects entities that have both AnimationNode and PhysicsNode. It clears the screen, applies the viewport, sets up the batch projection, then for each entity it advances the elapsed time, picks the correct animation frame and draws at the position (centred by subtracting half width/height).
BallSystem.java
package com.forgeleaf.tutorial.simpleworldgdx.systems;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.IntSet;
import com.badlogic.gdx.utils.viewport.Viewport;
import com.forgeleaf.simpleworld.World;
import com.forgeleaf.simpleworld.system.IteratingSystem;
import com.forgeleaf.tutorial.simpleworldgdx.node.BallNode;
import com.forgeleaf.tutorial.simpleworldgdx.node.PhysicsNode;
public class BallSystem extends IteratingSystem {
private final Viewport viewport;
private final Vector2 mouse;
public BallSystem(World world, Viewport viewport) {
super(world, 0, world.filter().allOf(BallNode.class, PhysicsNode.class));
this.viewport = viewport;
mouse = new Vector2();
}
@Override
protected void prepare() {
viewport.unproject(mouse.set(Gdx.input.getX(), Gdx.input.getY()));
}
@Override
protected void tick(float delta) {
World world = getWorld();
IntSet.IntSetIterator iterator = getEntities().iterator();
while (iterator.hasNext) {
int entity = iterator.next();
handle(world.get(entity, PhysicsNode.class));
}
}
private void handle(PhysicsNode physics) {
physics.velocity.set(mouse).sub(physics.position).nor().scl(physics.speed);
}
}
This system filters for BallNode + PhysicsNode. In prepare() we convert the screen coordinates of the mouse into game-world coordinates via viewport.unproject(). Then each frame we update the ballās velocity vector to point toward the mouse, normalized and scaled by the speed.
PhysicsSystem.java
package com.forgeleaf.tutorial.simpleworldgdx.systems;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.IntSet;
import com.forgeleaf.simpleworld.World;
import com.forgeleaf.simpleworld.system.IteratingSystem;
import com.forgeleaf.tutorial.simpleworldgdx.node.PhysicsNode;
public class PhysicsSystem extends IteratingSystem {
public PhysicsSystem(World world) {
super(world, 0, world.filter().allOf(PhysicsNode.class));
}
@Override
protected void tick(float delta) {
World world = getWorld();
IntSet.IntSetIterator iterator = getEntities().iterator();
while (iterator.hasNext) {
int entity = iterator.next();
handle(world.get(entity, PhysicsNode.class), delta);
}
}
private void handle(PhysicsNode physics, float delta) {
Vector2 position = physics.position;
Vector2 velocity = physics.velocity;
position.x += velocity.x * delta * physics.speed;
position.y += velocity.y * delta * physics.speed;
}
}
This system filters for all entities with PhysicsNode and advances their position according to velocity and speed. Simple movement logic.

Running the Game & Observations š
Once you build and run the project you should observe:
- Coins scattered randomly across your viewport, drifting slowly in random directions.
- A ball entity that follows your mouse pointer (or touch on mobile) smoothly.
- The coin sprites are animated (rotating) thanks to
AnimationNode+AnimationSystem. - Everything updates automatically via the ECS: you didnāt write a big āif entity is coin then do this else if entity is ball then do thatā ⦠the systems handle behaviours generically.
From here you can extend the demo:
- Add collision detection: when the ball touches a coin then remove the coin and increase score.
- Add sounds and music.
- Add UI: display score, lives, timer.
- Expand world size and implement camera follow behaviour.
- Export to desktop and mobile platforms via LibGDX.
Why Use an ECS with simple-world-gdx? š²
The answer of course depends on the context. Using an ECS is useful when you have a lot of different entity types that share some behavior but differ in little ways. You can composite different entity types by attaching different nodes (e.g. Components) or by separating the logic in your systems.
ECS are meant to be scalable. I’d recommend them because they have many different advantages over regular MVC patterns for most games:
- Clear separation of concerns: data lives in components/nodes, behavior in systems, rendering in the batch system.
- Easy to scale: when your game grows you can keep adding new nodes (for example
HealthNode,InputNode,AIStateNode) and new systems without bloating your main game class. - Reusability: Systems process many entities of a given type, you donāt need to write custom logic for each new entity type.
- Flexibility: You can attach/detach nodes at runtime (for example an entity can gain a
BallNodeor lose it) and systems automatically adapt. - Lightweight: simple-world-gdx is minimal and fits cleanly into LibGDX projects without heavy dependencies or complexity.
Wrapping up š¬
I hope you’ve learned something from my code example and this article. If you’ve got any questions or just want to tell me something then contact me! I’d appreciate every constructive feedback you have, please find the feedback form below.
The repo for this tutorial is, as usual, hosted on my GitHub: https://github.com/ForgeLeaf/SimpleWorldTutorial-gdx



