ForgeLeaf Logo

Consent to our cookie policy šŸŖ

We use a privacy-friendly, cookie-free analytics tool (Umami). Some cookies are used for functionality only. You may check our Privacy Policy.

Working with ECS in LibGDX 🤩

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

  1. We keep a List<Texture> textures so we can clean them up in dispose() reliably.
  2. We load the coin sprite sheet, slice it into frames of width 16px, and create a looping Animation<TextureRegion>.
  3. We load the ball sprite (single image) and create a one-frame animation (so our code treats both coins and ball uniformly).
  4. We create the SimpleBatch and a FitViewport(16,10) so our game world has width 16 units and height 10 units in world coordinates.
  5. We instantiate the World from simple-world-gdx and register three systems: AnimationSystem, BallSystem, PhysicsSystem.
  6. We spawn a random number of coin entities, each with an AnimationNode set to the coin animation, and a PhysicsNode whose position is randomly set in the viewport.
  7. We spawn the ball entity, and attach an AnimationNode for the ball sprite, a BallNode for special ball behaviour, and a PhysicsNode whose width/height we set to 2 units.
  8. In render() we call world.update(deltaTime) so that all systems run in sequence.
  9. In resize(), we update the viewport. In dispose(), 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 BallNode or 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

Give us some feedback!

Thanks for reading our article. Could you please consider giving us some constructive, anonymous feedback?

Related articles

Forge Framework

Forge is our free and open source game framework for the GoLang programming language. It is feature-rich and constantly improved. We designed Forge to be performant and simple for beginner, its power lies within its ecosystem and community. Forge is lightweight in nature and can be easily extended to fit most of your needs. Write you game logic in your way and let the framework handle compatibility.
Community

Connect with our community

ForgeLeaf Logo
Join the ForgeLeaf community — follow updates, contribute, and grow together.

Subscribe to our Newsletter

Stay Updated on the Latest Dev Insights. Join our mailing list to receive new articles, tutorials, and project updates.
ForgeLeaf Logo
Made with ā¤ļø in šŸ‡©šŸ‡Ŗ
Follow us on social media
Subscribe to our Newsletter
© 2025 ForgeLeaf Studio