A lightweight 2D game engine built on LWJGL 3 (OpenGL 3.3, OpenAL, GLFW) and JOML. Designed for top-down 2D games. Uses an entity-component-system (ECS) architecture.
- Java 17+
- Gradle (wrapper included)
- macOS ARM64 (native libs are configured for
macos-arm64— change inengine/build.gradle.ktsfor other platforms)
./gradlew :desktop:runDrop art assets into game/assets/textures/ and audio into game/assets/audio/ to replace the white placeholder tiles. The engine falls back to white 1×1 textures if files are missing, so the game runs without any assets.
engine/ — core engine (GPU, windowing, ECS, systems)
game/ — your game code and assets
desktop/ — entry point / launcher
Your game code lives entirely in the game module. The engine module should not need to be touched.
Every game is made of scenes. Extend GameScene and override four methods:
public class MyScene extends GameScene {
private final RenderSystem renderSystem = new RenderSystem();
private final PlayerMovementSystem moveSystem = new PlayerMovementSystem();
private final CollisionSystem collisionSystem = new CollisionSystem();
@Override
public void init(Window window, InputSystem input) {
// Build your scene here — create GameObjects and add components
}
@Override
public void update(InputSystem input) {
// Run your systems every frame
moveSystem.update(scene, input);
collisionSystem.update(scene);
}
@Override
public void render(Camera camera, Window window) {
// Move the camera, then render
camera.followCentered(player, window.getWidth(), window.getHeight());
renderSystem.render(scene, camera, window);
}
@Override
public void dispose() {
// Release resources (textures, audio sources) when the scene exits
}
}Pass your scene to EngineBootstrap.start() from the desktop launcher:
// desktop/src/main/java/threadfool/op/launcher/DesktopLauncher.java
public static void main(String[] args) {
EngineBootstrap.start(new MyScene());
}An entity that holds components. Components are stored by class — one per type.
GameObject obj = new GameObject();
obj.addComponent(new Transform());
obj.addComponent(new SpriteRenderer(myTexture));
obj.addComponent(new Collider(32, 32));
// Retrieve later
Transform t = obj.getComponent(Transform.class);Position, rotation (radians), and scale in 2D world space.
Transform t = new Transform();
t.position.set(200, 150); // world-space X, Y
t.scale.set(32, 32); // width, height in pixels
t.rotation = 0f; // radiansTransforms use pixel-space coordinates. The ortho camera maps pixels 1:1 to screen pixels at zoom=1.
A container for GameObjects. Systems iterate over it.
scene.add(obj);
// Query objects by component
List<GameObject> movables = scene.getObjectsWith(Transform.class);GameScene provides a scene field already — use this.scene inside init().
Texture tex = new Texture("game/assets/textures/player.png"); // filesystem pathPaths are relative to the working directory (project root when running via Gradle).
Attach a SpriteRenderer to any GameObject that has a Transform. The RenderSystem will draw it.
// Textured quad (most common)
obj.addComponent(new SpriteRenderer(myTexture));
// Coloured quad with a tint
SpriteRenderer sr = new SpriteRenderer(myTexture);
sr.tint.set(1f, 0.3f, 0.3f, 1f); // red tint
obj.addComponent(sr);Call renderSystem.render(scene, camera, window) inside your scene's render() method. It draws every GameObject that has both a SpriteRenderer and a Transform.
The Camera is owned by GameLoop and passed into render(). Common operations:
// Follow a target, centering it on screen
camera.followCentered(player, window.getWidth(), window.getHeight());
// Zoom in/out
camera.zoom = 2f; // 2x zoom
// Free-move
camera.position.set(x, y);Poll keyboard state inside update() via the InputSystem argument:
import org.lwjgl.glfw.GLFW;
if (input.isKeyDown(GLFW.GLFW_KEY_SPACE)) {
// jump, shoot, etc.
}Key constants are in org.lwjgl.glfw.GLFW — GLFW_KEY_W, GLFW_KEY_LEFT, etc.
Attach PlayerInput to a GameObject and run PlayerMovementSystem each frame for free WASD movement:
player.addComponent(new PlayerInput()); // speed defaults to 100.0
// Increase speed:
player.getComponent(PlayerInput.class).speed = 200f;// In update():
moveSystem.update(scene, input);// Dynamic (gets pushed out of static objects)
obj.addComponent(new Collider(32, 32));
// Static (immovable obstacle)
obj.addComponent(new Collider(64, 64, true));
// With offset (AABB doesn't start at transform.position)
Collider c = new Collider(24, 24);
c.offsetX = 4f;
c.offsetY = 4f;
obj.addComponent(c);The collider size is independent of the Transform scale — set them to match when you want pixel-perfect boxes.
Run it every frame before rendering. It pushes dynamic objects out of anything they overlap:
// In update():
collisionSystem.update(scene);No callbacks yet — resolution is purely positional push.
AudioContext is initialised automatically by EngineBootstrap. Nothing to do.
SoundBuffer sfx = SoundBuffer.loadWav("game/assets/audio/footstep.wav"); // WAV (PCM)
SoundBuffer music = SoundBuffer.loadOgg("game/assets/audio/theme.ogg"); // OGG/VorbisSoundSource source = new SoundSource();
source.setBuffer(music);
source.setLooping(true);
source.setGain(0.5f); // 0.0 – 1.0
source.play();
// Later
source.pause();
source.stop();
source.dispose(); // call in scene.dispose()// In init():
SoundBuffer music = SoundBuffer.loadOgg("game/assets/audio/theme.ogg");
musicSource = new SoundSource();
musicSource.setBuffer(music);
musicSource.setLooping(true);
musicSource.play();
// Attach to a dummy object so SoundSystem can manage it
GameObject musicObj = new GameObject();
musicObj.addComponent(new Transform());
musicObj.addComponent(musicSource);
scene.add(musicObj);
// In dispose():
soundSystem.stopAll(scene);
musicSource.dispose();Switch scenes from anywhere via SceneManager:
// From inside a scene — pass SceneManager in or store a reference
sceneManager.switchTo(new GameOverScene()); // replaces current, calls dispose()
sceneManager.push(new PauseMenuScene()); // overlay — current scene stays beneath
sceneManager.pop(); // return to previous sceneGameLoop owns the SceneManager. To access it from a scene you can pass it in via the constructor, or trigger the switch by storing a reference from init().
Use Time.delta() for frame-rate-independent movement (seconds since last frame):
t.position.x += speed * Time.delta();PlayerMovementSystem already uses this.
For custom geometry beyond quads:
// Solid-colour triangle (no UVs, stride 3)
float[] verts = { -0.5f, -0.5f, 0f, 0.5f, -0.5f, 0f, 0f, 0.5f, 0f };
Mesh mesh = new Mesh(verts);
// Textured quad (UVs interleaved, stride 5: X Y Z U V)
Mesh quad = Mesh.createQuad(); // ready-made unit quad
// Custom textured geometry
float[] uvVerts = {
-0.5f, -0.5f, 0f, 0f, 0f,
0.5f, -0.5f, 0f, 1f, 0f,
0.0f, 0.5f, 0f, 0.5f, 1f,
};
Mesh tri = new Mesh(uvVerts, true);Pair with a Shader and use SpriteRenderer(mesh, shader) to render it.
Systems are plain classes. They receive a Scene and iterate over objects with specific components:
public class HealthSystem {
public void update(Scene scene) {
for (GameObject obj : scene.getObjectsWith(Health.class)) {
Health h = obj.getComponent(Health.class);
if (h.current <= 0) {
// handle death
}
}
}
}Instantiate it in your GameScene and call update() from update(InputSystem).
| Asset type | Directory |
|---|---|
| Textures (PNG) | game/assets/textures/ |
| Audio — music (OGG) | game/assets/audio/ |
| Audio — SFX (WAV) | game/assets/audio/ |
Paths are relative to the project root. When running with ./gradlew :desktop:run the working directory is the project root.
Natives are configured in engine/build.gradle.kts. Change the classifier to match your platform:
| Platform | Classifier |
|---|---|
| macOS ARM (M1/M2/M3) | natives-macos-arm64 |
| macOS Intel | natives-macos |
| Linux x64 | natives-linux |
| Windows x64 | natives-windows |
