| name | fxgl-platformer |
| description | Build a side-scrolling platformer in FXGL — set up physics with gravity for a player entity, implement jump with coyote time and jump buffer, handle one-way pass-through platforms, attach moving kinematic platforms the player can ride, bind the camera viewport to the player with world bounds, add parallax scrolling background layers, load Tiled TMX levels with solid tile collision, detect pit/fall-out-of-bounds deaths, stomp enemies from above, and implement a checkpoint and respawn system. Use this skill when building a Mario-style, precision platformer, run-and-jump, or any side-scrolling game with gravity-driven movement.
|
| triggers | ["platformer","side-scroller","jump","gravity","platform","coyote time","jump buffer","one-way platform","moving platform","parallax","stomp","Mario-style"] |
| compatibility | Java 17+, FXGL 21.x
|
| category | fxgl/game-types |
| tags | ["fxgl","java","javafx","game-types","platformer"] |
| metadata | {"author":"fxgl-skills","version":"1.0","fxgl-version":"21.1"} |
| allowed-tools | ["Read","Write","Edit","Bash"] |
FXGL Platformer
Player Entity Setup
@Spawns("player")
public Entity newPlayer(SpawnData data) {
PhysicsComponent physics = new PhysicsComponent();
physics.setBodyType(BodyType.DYNAMIC);
FixtureDef fd = new FixtureDef();
fd.setFriction(0.0f);
fd.setDensity(1.0f);
physics.addFixtureDef(fd);
return entityBuilder(data)
.type(EntityType.PLAYER)
.view("player.png")
.bbox(BoundingShape.box(32, 48))
.with(physics)
.with(new CollidableComponent(true))
.with(new PlayerComponent())
.build();
}
PlayerComponent — Jump, Coyote Time, Jump Buffer
public class PlayerComponent extends Component {
private PhysicsComponent physics;
private boolean isGrounded = false;
private double coyoteTimer = 0;
private double jumpBufferTimer = 0;
private static final double JUMP_FORCE = 14.0;
private static final double MOVE_SPEED = 200.0;
private static final double COYOTE_TIME = 0.15;
private static final double JUMP_BUFFER = 0.12;
@Override
public void onUpdate(double tpf) {
if (!isGrounded) coyoteTimer = Math.max(0, coyoteTimer - tpf);
else coyoteTimer = COYOTE_TIME;
if (jumpBufferTimer > 0) jumpBufferTimer -= tpf;
if (isGrounded && jumpBufferTimer > 0) {
jumpBufferTimer = 0;
performJump();
}
double dx = 0;
if (getInput().isHeld(KeyCode.A) || getInput().isHeld(KeyCode.LEFT)) dx -= MOVE_SPEED;
if (getInput().isHeld(KeyCode.D) || getInput().isHeld(KeyCode.RIGHT)) dx += MOVE_SPEED;
physics.setVelocityX(dx);
if (dx < 0) entity.setScaleX(-1);
else if (dx > 0) entity.setScaleX(1);
if (!getInput().isHeld(KeyCode.SPACE) && physics.getVelocityY() < 0) {
physics.setVelocityY(physics.getVelocityY() * 0.85);
}
isGrounded = false;
}
public void onJumpPressed() {
if (coyoteTimer > 0) {
coyoteTimer = 0;
performJump();
} else {
jumpBufferTimer = JUMP_BUFFER;
}
}
private void performJump() {
physics.setVelocityY(-JUMP_FORCE);
play("sounds/jump.wav");
}
public void setGrounded(boolean grounded) {
this.isGrounded = grounded;
}
public boolean isGrounded() { return isGrounded; }
}
Input Binding
@Override
protected void initInput() {
onKeyDown(KeyCode.SPACE, () -> player.getComponent(PlayerComponent.class).onJumpPressed());
onKeyDown(KeyCode.UP, () -> player.getComponent(PlayerComponent.class).onJumpPressed());
}
Ground Detection via Sensor
entityBuilder(data)
.with(physics)
.buildAndAttach();
@Override
protected void initPhysics() {
onCollisionBegin(EntityType.PLAYER, EntityType.GROUND, (player, ground) -> {
double playerBottom = player.getY() + player.getHeight();
double groundTop = ground.getY();
if (playerBottom <= groundTop + 8) {
player.getComponent(PlayerComponent.class).setGrounded(true);
}
});
onCollisionEnd(EntityType.PLAYER, EntityType.GROUND, (player, ground) -> {
});
}
One-Way Platforms
@Spawns("oneWayPlatform")
public Entity newOneWayPlatform(SpawnData data) {
PhysicsComponent physics = new PhysicsComponent();
physics.setBodyType(BodyType.STATIC);
FixtureDef fd = new FixtureDef();
fd.restitution = 0.0f;
physics.addFixtureDef(fd);
return entityBuilder(data)
.type(EntityType.ONE_WAY_PLATFORM)
.view(new Rectangle(data.<Integer>get("width"), 12, Color.BROWN))
.bbox(BoundingShape.box(data.get("width"), 12))
.with(physics)
.build();
}
Moving Platforms
@Spawns("movingPlatform")
public Entity newMovingPlatform(SpawnData data) {
PhysicsComponent physics = new PhysicsComponent();
physics.setBodyType(BodyType.KINEMATIC);
Entity platform = entityBuilder(data)
.type(EntityType.GROUND)
.view(new Rectangle(128, 16, Color.DARKGREEN))
.bbox(BoundingShape.box(128, 16))
.with(physics)
.with(new MovingPlatformComponent(data.get("startX"), data.get("endX"), 80))
.build();
return platform;
}
public class MovingPlatformComponent extends Component {
private PhysicsComponent physics;
private final double startX, endX, speed;
private int direction = 1;
@Override
public void onUpdate(double tpf) {
physics.setVelocityX(direction * speed);
if (entity.getX() >= endX) direction = -1;
if (entity.getX() <= startX) direction = 1;
}
}
Camera Binding with Bounds
@Override
protected void initGame() {
setLevelFromMap("level1.tmx");
Viewport viewport = getGameScene().getViewport();
viewport.bindToEntity(player, getAppWidth() / 2.0, getAppHeight() / 2.0);
viewport.setBounds(-64, 0, LEVEL_WIDTH * 32, LEVEL_HEIGHT * 32);
}
Parallax Background
private void initParallax() {
Entity bg0 = entityBuilder()
.at(-100, 0)
.view(new ImageView(image("bg_far.png")))
.zIndex(-3)
.buildAndAttach();
Entity bg1 = entityBuilder()
.at(0, 0)
.view(new ImageView(image("bg_mid.png")))
.zIndex(-2)
.buildAndAttach();
}
@Override
protected void onUpdate(double tpf) {
double camX = getGameScene().getViewport().getX();
bg0.setX(camX * 0.1);
bg1.setX(camX * 0.4);
}
Pit Detection
@Spawns("pitSensor")
public Entity newPitSensor(SpawnData data) {
return entityBuilder(data)
.type(EntityType.PIT)
.bbox(BoundingShape.box(LEVEL_WIDTH * 32, 64))
.with(new PhysicsComponent())
.build();
}
onCollisionBegin(EntityType.PLAYER, EntityType.PIT, (player, pit) -> {
onPlayerDeath();
});
Enemy Stomp Pattern
onCollisionBegin(EntityType.PLAYER, EntityType.ENEMY, (player, enemy) -> {
PlayerComponent pc = player.getComponent(PlayerComponent.class);
double playerBottomY = player.getY() + player.getHeight();
double enemyTopY = enemy.getY();
if (player.getComponent(PhysicsComponent.class).getVelocityY() > 0
&& playerBottomY <= enemyTopY + 12) {
enemy.removeFromWorld();
inc("score", 100);
player.getComponent(PhysicsComponent.class).setVelocityY(-8);
play("sounds/stomp.wav");
} else {
onPlayerHurt(player);
}
});
Checkpoint System
onCollisionBegin(EntityType.PLAYER, EntityType.CHECKPOINT, (player, checkpoint) -> {
if (!checkpoint.getBoolean("activated")) {
checkpoint.set("activated", true);
set("spawnX", checkpoint.getX());
set("spawnY", checkpoint.getY());
checkpoint.getViewComponent().setOpacity(0.5);
play("sounds/checkpoint.wav");
}
});
private void respawnPlayer() {
player.setPosition(getd("spawnX"), getd("spawnY"));
player.getComponent(PhysicsComponent.class).setVelocityX(0);
player.getComponent(PhysicsComponent.class).setVelocityY(0);
}
Gotchas
- Zero friction on player is critical — without
fd.setFriction(0), the player sticks
to walls and slides along them due to Box2D's default friction. Always set friction=0 on
the player body fixture.
setVelocityX not applyForce for horizontal movement — force-based movement feels
sluggish and overshoots. Velocity setting gives tight, predictable control.
- Coyote time and jump buffer must both be implemented — without them, jumps at ledge
edges feel unresponsive and players feel cheated. Both are industry-standard patterns.
isGrounded reset each frame — reset isGrounded = false in onUpdate, then set
it to true only when collision confirms it. Do NOT set it in onCollisionEnd because
that fires when one platform ends while another begins.
- Moving platforms: player must be a DYNAMIC body — kinematic-on-kinematic collision
doesn't work in Box2D. Player must be DYNAMIC for moving platform physics to transfer.
- Viewport
bindToEntity centers on entity origin, not center — pass appWidth/2, appHeight/2 as offset to center the player on screen, or adjust for different screen regions.
- Parallax layers need to be at a low zIndex — use
zIndex(-3) or lower so game
entities render in front. FXGL defaults entities to zIndex 0.