Two common issues remain after implementing basic TiledMap rendering and player movement in LibGDX: unclamped camera boundaries that allow viewing outside the map area, and no collision detection for non-passable obstacles.
Camera Boundary Clamping
The camera's position defines the center of the viewport, not the screen edges, so simple clamping of the camera position will not work correctly. The simplest solution is to always calculate the viewport's actual edges based on the candidate camera position, and only allow movement that keeps the entire viewport within the map bounds.
A naive initial implementation blocks all movement when hitting the boundary:
private void updateCamera(Vector3 movementDelta, Actor playerActor) {
Vector3 newCamPos = gameStage.getCamera().position.cpy().add(movementDelta);
Vector3 upperRightBound = new Vector3(screenWidth / 2, screenHeight / 2, 0).add(newCamPos);
if (upperRightBound.x > maxCameraBounds.x || upperRightBound.y > maxCameraBounds.y) {
return;
}
Vector3 lowerLeftBound = new Vector3(-screenWidth / 2, -screenHeight / 2, 0).add(newCamPos);
if (lowerLeftBound.x < 0 || lowerLeftBound.y < 0) {
return;
}
gameStage.getCamera().position.add(movementDelta);
for (Actor actor : gameStage.getActors()) {
actor.x += movementDelta.x;
actor.y += movementDelta.y;
}
}
This approach has a critical flaw: when the camera reaches the map edge, the player is also blocked from moving further, which is incorrect behavior. The fix is to separate camera movement and player movement, allowing the player to keep moving across the screen even when the camera can no longer scroll.
The corrected implementation:
boolean canMoveCamera = true;
boolean canMovePlayer = true;
Vector3 newCamPos = gameStage.getCamera().position.cpy().add(movementDelta);
Vector3 upperRightBound = new Vector3(screenWidth / 2, screenHeight / 2, 0).add(newCamPos);
if (upperRightBound.x > maxCameraBounds.x || upperRightBound.y > maxCameraBounds.y) {
canMoveCamera = false;
}
Vector3 lowerLeftBound = new Vector3(-screenWidth / 2, -screenHeight / 2, 0).add(newCamPos);
if (lowerLeftBound.x < 0 || lowerLeftBound.y < 0) {
canMoveCamera = false;
}
// Check if player would go off-screen after movement
Vector3 playerScreenPos = new Vector3(playerActor.x, playerActor.y, 0);
gameStage.getCamera().project(playerScreenPos);
Vector3 newPlayerScreenPos = playerScreenPos.cpy().add(movementDelta);
if (newPlayerScreenPos.x > screenWidth || newPlayerScreenPos.y > screenHeight
|| newPlayerScreenPos.x < 0 || newPlayerScreenPos.y < 0) {
canMovePlayer = false;
}
if (canMoveCamera) {
gameStage.getCamera().position.add(movementDelta);
// Only shift static actors when camera moves, player moves independently
for (Actor actor : gameStage.getActors()) {
if (!actor.equals(player)) {
actor.x += movementDelta.x;
actor.y += movementDelta.y;
}
}
}
if (canMovePlayer) {
player.x += movementDelta.x;
player.y += movementDelta.y;
}
This solves the boundary issue for most cases. For a more polished experience, you can add an additional check to only move the camera when the player is near the edge of the screen, to prevent the player from getting stuck in a corner.
Obstacle Collision Handling
To add non-passable obstacles using TiledMap, you first prepare the map in the Tiled editor:
- Add a new 32x32 obstacle tile to your tileset.
- Create a new layer named
NoPassfor collision data. - Fill all non-wlakable areas of the map with the obstacle tile on this layer.
- Add a custom property
Passwith valueFalseto the obstacle tile. - Hide the collision layer in the editor (LibGDX will still load it for collision checking, so we remove it from rendering manually).
After loading the map in code, extract the collision layer and remove it from the render list:
map = TiledLoader.createMap(mapHandle);
TiledLayer obstacleLayer = null;
for (int i = 0; i < map.layers.size(); i++) {
if ("NoPass".equals(map.layers.get(i).name)) {
obstacleLayer = map.layers.get(i);
map.layers.remove(i);
break;
}
}
Next, find the tile ID of the non-passable obstacle tile during initialization:
int blockedTileId = 0;
TileSet targetTileSet = map.tileSets.get(map.tileSets.size() - 1);
int searchRangeEnd = targetTileSet.firstgid + obstacleLayer.tiles.length;
for (int i = 0; i < searchRangeEnd; i++) {
if ("False".equals(map.getTileProperty(i, "Pass"))) {
blockedTileId = i;
Gdx.app.log("Collision Setup", "Found blocked tile ID: " + i);
break;
}
}
Before processing player movement, check if the new position would overlap a blocked tile. We check all four corners of the player's bounding box for reliable collision detection:
private boolean isMovementBlocked(TiledMap map, TiledLayer obstacleLayer,
Vector3 movementDelta, Vector2 startPos) {
Vector2 newPos = new Vector2(startPos.x + movementDelta.x, startPos.y + movementDelta.y);
int tileX = MathUtils.ceilPositive(newPos.x / map.tileWidth);
int tileY = MathUtils.ceilPositive(newPos.y / map.tileHeight);
// Flip Y coordinate to match Tiled's top-left origin system
if (obstacleLayer.tiles[obstacleLayer.tiles.length - tileY][tileX - 1] == blockedTileId) {
return true;
}
return false;
}
Call this check before processing movement, and block the movement if it returns true:
Vector2 currentPos = new Vector2(player.x, player.y);
if (isMovementBlocked(map, obstacleLayer, movementDelta, currentPos)) {
return;
}
Full working implementation:
package com.example.libgdxgame;
import com.badlogic.gdx.ApplicationListener;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputMultiplexer;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.files.FileHandle;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL10;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.graphics.g2d.tiled.TileAtlas;
import com.badlogic.gdx.graphics.g2d.tiled.TileMapRenderer;
import com.badlogic.gdx.graphics.g2d.tiled.TileSet;
import com.badlogic.gdx.graphics.g2d.tiled.TiledLayer;
import com.badlogic.gdx.graphics.g2d.tiled.TiledLoader;
import com.badlogic.gdx.graphics.g2d.tiled.TiledMap;
import com.badlogic.gdx.graphics.g2d.tiled.TiledObject;
import com.badlogic.gdx.graphics.g2d.tiled.TiledObjectGroup;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.math.Vector3;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Image;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.scenes.scene2d.ui.Label.LabelStyle;
public class TiledMapGame implements ApplicationListener, InputProcessor {
Stage gameStage;
float screenWidth;
float screenHeight;
private TiledMap map;
private TileAtlas tileAtlas;
private TileMapRenderer tileRenderer;
Image player;
Vector3 moveDirection = new Vector3(1, 1, 0);
Vector2 maxCameraBounds = new Vector2(0, 0);
Vector3 currentMovement = new Vector3(0, 0, 0);
boolean isMoving = false;
TiledLayer obstacleLayer;
int blockedTileId;
@Override
public void create() {
final String mapPath = "map/";
final String mapName = "tilemap";
FileHandle mapHandle = Gdx.files.internal(mapPath + mapName + ".tmx");
map = TiledLoader.createMap(mapHandle);
// Extract collision layer
for (int i = 0; i < map.layers.size(); i++) {
if ("NoPass".equals(map.layers.get(i).name)) {
obstacleLayer = map.layers.get(i);
break;
}
}
// Find blocked tile ID
TileSet lastSet = map.tileSets.get(map.tileSets.size() - 1);
int end = lastSet.firstgid + obstacleLayer.tiles.length;
for (int i = 0; i < end; i++) {
if ("False".equals(map.getTileProperty(i, "Pass"))) {
blockedTileId = i;
break;
}
}
tileAtlas = new TileAtlas(map, new FileHandle("map/"));
tileRenderer = new TileMapRenderer(map, tileAtlas, 10, 10);
maxCameraBounds.set(tileRenderer.getMapWidthUnits(), tileRenderer.getMapHeightUnits());
screenWidth = Gdx.graphics.getWidth();
screenHeight = Gdx.graphics.getHeight();
gameStage = new Stage(screenWidth, screenHeight, true);
Label fpsLabel = new Label("FPS:", new LabelStyle(
new BitmapFont(Gdx.files.internal("font/blue.fnt"), Gdx.files.internal("font/blue.png"), false), Color.WHITE), "fpsLabel");
fpsLabel.y = screenHeight - fpsLabel.getPrefHeight();
fpsLabel.x = 0;
gameStage.addActor(fpsLabel);
// Spawn player from object group
for (TiledObjectGroup group : map.objectGroups) {
for (TiledObject obj : group.objects) {
if ("player".equals(obj.name)) {
player = new Image(new TextureRegion(
new Texture(Gdx.files.internal("map/player.png")), 0, 0, 27, 40));
player.x = obj.x;
player.y = tileRenderer.getMapHeightUnits() - obj.y;
gameStage.addActor(player);
}
}
}
InputMultiplexer input = new InputMultiplexer();
input.addProcessor(this);
input.addProcessor(gameStage);
Gdx.input.setInputProcessor(input);
}
@Override
public void render() {
Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
OrthographicCamera camera = (OrthographicCamera) gameStage.getCamera();
if (isMoving) {
updateCameraAndPlayer(currentMovement, player);
}
((Label) gameStage.findActor("fpsLabel")).setText("FPS: " + Gdx.graphics.getFramesPerSecond());
gameStage.act(Gdx.graphics.getDeltaTime());
tileRenderer.render(camera);
gameStage.draw();
}
private void updateCameraAndPlayer(Vector3 movementDelta, Actor playerActor) {
// Check all four corners for collision
Vector2 currentPos = new Vector2(playerActor.x, playerActor.y);
if (isMovementBlocked(map, obstacleLayer, movementDelta, currentPos)) return;
if (isMovementBlocked(map, obstacleLayer, movementDelta, currentPos.cpy().add(player.width, 0))) return;
if (isMovementBlocked(map, obstacleLayer, movementDelta, currentPos.cpy().add(player.width, player.height))) return;
if (isMovementBlocked(map, obstacleLayer, movementDelta, currentPos.cpy().add(0, player.height))) return;
boolean canMoveCamera = true;
boolean canMovePlayer = true;
Vector3 newCamPos = gameStage.getCamera().position.cpy().add(movementDelta);
Vector3 upperRight = new Vector3(screenWidth / 2, screenHeight / 2, 0).add(newCamPos);
if (upperRight.x > maxCameraBounds.x || upperRight.y > maxCameraBounds.y) {
canMoveCamera = false;
}
Vector3 lowerLeft = new Vector3(-screenWidth / 2, -screenHeight / 2, 0).add(newCamPos);
if (lowerLeft.x < 0 || lowerLeft.y < 0) {
canMoveCamera = false;
}
Vector3 playerScreenPos = new Vector3(playerActor.x, playerActor.y, 0);
gameStage.getCamera().project(playerScreenPos);
Vector3 newPlayerScreen = playerScreenPos.cpy().add(movementDelta);
if (newPlayerScreen.x > screenWidth || newPlayerScreen.y > screenHeight) {
canMovePlayer = false;
}
if (newPlayerScreen.x < 0 || newPlayerScreen.y < 0) {
canMovePlayer = false;
}
if (canMoveCamera) {
gameStage.getCamera().position.add(movementDelta);
for (Actor actor : gameStage.getActors()) {
if (!actor.equals(player)) {
actor.x += movementDelta.x;
actor.y += movementDelta.y;
}
}
}
if (canMovePlayer) {
player.x += movementDelta.x;
player.y += movementDelta.y;
}
}
private boolean isMovementBlocked(TiledMap map, TiledLayer layer, Vector3 delta, Vector2 startPos) {
Vector2 newPos = new Vector2(startPos.x + delta.x, startPos.y + delta.y);
int xTile = MathUtils.ceilPositive(newPos.x / map.tileWidth);
int yTile = MathUtils.ceilPositive(newPos.y / map.tileHeight);
if (layer.tiles[layer.tiles.length - yTile][xTile - 1] == blockedTileId) {
return true;
}
return false;
}
private void updateDirection(int directionId) {
switch (directionId) {
case 1:
currentMovement.set(0, 1, 0);
break;
case 2:
currentMovement.set(0, -1, 0);
break;
case 3:
currentMovement.set(-1, 0, 0);
break;
case 4:
currentMovement.set(1, 0, 0);
break;
}
}
@Override
public boolean touchDown(int x, int y, int pointer, int button) {
Vector3 tmp = new Vector3(x, y, 0);
gameStage.getCamera().unproject(tmp);
float deltaX = tmp.x - player.x;
float deltaY = tmp.y - player.y;
if (deltaX > 0 && deltaY > 0) {
if (deltaX > deltaY) updateDirection(4);
else updateDirection(1);
} else if (deltaX > 0 && deltaY < 0) {
if (deltaX > -deltaY) updateDirection(4);
else updateDirection(2);
} else if (deltaX < 0 && deltaY > 0) {
if (-deltaX > deltaY) updateDirection(3);
else updateDirection(1);
} else {
if (-deltaX > -deltaY) updateDirection(3);
else updateDirection(2);
}
isMoving = true;
return false;
}
@Override
public boolean touchUp(int x, int y, int pointer, int button) {
isMoving = false;
return false;
}
@Override public void dispose() {}
@Override public void pause() {}
@Override public void resize(int width, int height) {}
@Override public void resume() {}
@Override public boolean keyDown(int keycode) { return false; }
@Override public boolean keyTyped(char character) { return false; }
@Override public boolean keyUp(int keycode) { return false; }
@Override public boolean scrolled(int amount) { return false; }
@Override public boolean touchDragged(int x, int y, int pointer) { return false; }
@Override public boolean touchMoved(int x, int y) { return false; }
}
- Remove the obstacle layer from the map layers list after loading it for collision data to prevent it from being rendered.
- Always account for the difference in coordinate system origin between Tiled and LibGDX when calculating tile positions.
- Dynamic obstacles can be modified at runtime by directly editing the 2D tiles array of the colllision layer.
- This is one simple approach to tile-based collision; you can extend it to check all tiles the player overlaps for larger or irregular sprites.