FXGL Beat 'em Up Brawler
Stage Architecture
// Stage = sequence of "sections", each with enemy spawns.
// Camera scrolls right when section is cleared.
// Next section spawns when camera reaches the new area.
private int currentSection = 0;
private final List<SectionData> sections = List.of(
new SectionData(List.of("thug", "thug", "thug"), 400),
new SectionData(List.of("thug", "heavy", "thug"), 800),
new SectionData(List.of("heavy", "heavy", "archer"), 1200),
new SectionData(List.of("boss"), 1600) // boss section
);
private void checkSectionClear() {
if (getGameWorld().getEntitiesByType(EntityType.ENEMY).isEmpty()) {
currentSection++;
if (currentSection < sections.size()) {
advanceCameraToSection(currentSection);
} else {
showStageComplete();
}
}
}
private void advanceCameraToSection(int sectionIdx) {
SectionData section = sections.get(sectionIdx);
double targetX = section.cameraX();
// Animate camera scroll to next area
animationBuilder()
.duration(Duration.seconds(1.5))
.onFinished(() -> spawnSectionEnemies(section))
.animate(getGameScene().getViewport().xProperty())
.from(getGameScene().getViewport().getX())
.to(targetX)
.buildAndPlay();
}
private void spawnSectionEnemies(SectionData section) {
double camX = getGameScene().getViewport().getX();
int i = 0;
for (String type : section.enemyTypes()) {
spawn(type, new SpawnData(camX + getAppWidth() - 100 + i * 80.0,
GROUND_Y - 40));
i++;
}
}
Player Component
public class BrawlerPlayerComponent extends Component {
private PhysicsComponent physics;
private static final double MOVE_SPEED = 180.0;
private double attackCooldown = 0;
@Override
public void onUpdate(double tpf) {
if (attackCooldown > 0) attackCooldown -= tpf;
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;
// Limit horizontal movement to camera bounds
double camLeft = getGameScene().getViewport().getX();
double camRight = camLeft + getAppWidth();
if ((dx < 0 && entity.getX() <= camLeft + 20) ||
(dx > 0 && entity.getX() >= camRight - 60)) {
dx = 0;
}
physics.setVelocityX(dx);
if (dx > 0) entity.setScaleX(1);
else if (dx < 0) entity.setScaleX(-1);
// Also allow vertical (depth) movement in brawler field
double dy = 0;
if (getInput().isHeld(KeyCode.W) || getInput().isHeld(KeyCode.UP)) dy -= MOVE_SPEED * 0.6;
if (getInput().isHeld(KeyCode.S) || getInput().isHeld(KeyCode.DOWN)) dy += MOVE_SPEED * 0.6;
physics.setVelocityY(dy);
}
public void attack() {
if (attackCooldown > 0) return;
attackCooldown = 0.35;
spawnAttackHitbox();
play("sounds/punch.wav");
}
private void spawnAttackHitbox() {
double facingDir = entity.getScaleX();
double hbX = entity.getX() + (facingDir > 0 ? entity.getWidth() : -60);
double hbY = entity.getY() + 10;
entityBuilder()
.type(EntityType.PLAYER_HITBOX)
.at(hbX, hbY)
.bbox(BoundingShape.box(60, 40))
.with(new CollidableComponent(true))
.set("damage", 15)
.set("facingDir", facingDir)
.with(new ExpireCleanComponent(Duration.millis(100)))
.buildAndAttach();
}
}
Hit Detection with Hitstop
@Override
protected void initPhysics() {
onCollisionBegin(EntityType.PLAYER_HITBOX, EntityType.ENEMY, (hitbox, enemy) -> {
int damage = hitbox.getInt("damage");
double kickDir = hitbox.getDouble("facingDir");
// Apply damage
enemy.getComponent(HPComponent.class).damage(damage);
// Knockback
PhysicsComponent ep = enemy.getComponent(PhysicsComponent.class);
ep.setVelocityX(kickDir * 350);
ep.setVelocityY(-150); // small upward pop
// Hitstop: freeze both player and enemy for ~5 frames
applyHitStop(player, Duration.millis(80));
applyHitStop(enemy, Duration.millis(80));
// Combo
incrementCombo();
// Check death
if (enemy.getComponent(HPComponent.class).isDead()) {
onEnemyDeath(enemy);
}
play("sounds/hit.wav");
});
onCollisionBegin(EntityType.ENEMY_HITBOX, EntityType.PLAYER, (hitbox, pl) -> {
pl.getComponent(HPComponent.class).damage(hitbox.getInt("damage"));
applyHitStop(pl, Duration.millis(100));
resetCombo();
});
}
private void applyHitStop(Entity e, Duration duration) {
e.getComponent(PhysicsComponent.class).setVelocityX(0);
e.getComponent(PhysicsComponent.class).setVelocityY(0);
// Pause AI/player component for duration
e.getComponent(Component.class).pause();
runOnce(() -> e.getComponent(Component.class).resume(), duration);
}
private void onEnemyDeath(Entity enemy) {
// Random food drop
if (FXGLMath.random() < 0.25) {
spawn("food", enemy.getX(), enemy.getY());
}
enemy.removeFromWorld();
checkSectionClear();
}
Combo Counter
private int comboCount = 0;
private double comboTimer = 0;
private static final double COMBO_TIMEOUT = 2.0;
private void incrementCombo() {
comboCount++;
comboTimer = COMBO_TIMEOUT;
showComboText(comboCount);
}
private void resetCombo() {
if (comboCount >= 5) {
// Bonus score for ending a 5+ hit combo
inc("score", comboCount * 20);
}
comboCount = 0;
hideComboText();
}
@Override
protected void onUpdate(double tpf) {
if (comboCount > 0) {
comboTimer -= tpf;
if (comboTimer <= 0) resetCombo();
}
// ... stage scrolling updates ...
}
Enemy Crowd AI
public class BrawlerEnemyComponent extends Component {
private enum Role { APPROACHING, CIRCLING, WAITING }
private PhysicsComponent physics;
private Role role = Role.APPROACHING;
private double attackTimer = 0;
private double circleAngle = FXGLMath.random(0, 360);
@Override
public void onUpdate(double tpf) {
Entity pl = getGameWorld().getSingleton(EntityType.PLAYER);
double dist = entity.getCenter().distance(pl.getCenter());
// Assign role based on how many enemies are already close
long closeEnemies = getGameWorld().getEntitiesByType(EntityType.ENEMY)
.stream()
.filter(e -> e != entity && e.getCenter().distance(pl.getCenter()) < 80)
.count();
role = (closeEnemies >= 2) ? Role.CIRCLING
: (dist > 200) ? Role.APPROACHING
: Role.APPROACHING;
switch (role) {
case APPROACHING -> {
Point2D dir = pl.getCenter().subtract(entity.getCenter()).normalize();
physics.setVelocityX(dir.getX() * 100);
physics.setVelocityY(dir.getY() * 60);
if (dist < 70) {
attackTimer += tpf;
if (attackTimer >= 1.5) {
attackTimer = 0;
performAttack();
}
}
}
case CIRCLING -> {
// Orbit player
circleAngle += 60 * tpf;
double rad = Math.toRadians(circleAngle);
double targetX = pl.getX() + Math.cos(rad) * 100;
double targetY = pl.getY() + Math.sin(rad) * 60;
Point2D toTarget = new Point2D(targetX - entity.getX(), targetY - entity.getY()).normalize();
physics.setVelocityX(toTarget.getX() * 80);
physics.setVelocityY(toTarget.getY() * 50);
}
}
}
private void performAttack() {
entityBuilder()
.type(EntityType.ENEMY_HITBOX)
.at(entity.getCenter().getX(), entity.getCenter().getY())
.bbox(BoundingShape.box(50, 30))
.with(new CollidableComponent(true))
.set("damage", 8)
.with(new ExpireCleanComponent(Duration.millis(120)))
.buildAndAttach();
play("sounds/enemy_attack.wav");
}
}
Food Pickup (Health Restore)
@Spawns("food")
public Entity newFood(SpawnData data) {
return entityBuilder(data)
.type(EntityType.FOOD)
.view("food_apple.png")
.bbox(BoundingShape.box(32, 32))
.with(new CollidableComponent(true))
.build();
}
@Override
protected void initPhysics() {
onCollisionBegin(EntityType.PLAYER, EntityType.FOOD, (pl, food) -> {
int heal = 30;
HPComponent hp = pl.getComponent(HPComponent.class);
hp.setValue(Math.min(hp.getMaxValue(), hp.getValue() + heal));
food.removeFromWorld();
play("sounds/eat.wav");
});
}
Gotchas
- Brawler uses both X and Y movement — unlike top-down games (no gravity), brawlers move
on a 2D plane: left/right for horizontal and up/down for depth (Y). Gravity is disabled.
The player can only fall during knockback (brief Y impulse) but returns to the ground plane.
- Camera lock during enemy wave — prevent the camera from scrolling past the next section
boundary until all enemies are cleared. Use viewport bounds clamping.
- Hitstop via component pause —
Component.pause() stops onUpdate for that component.
This is the cleanest way to freeze entity behavior briefly. Resume with Component.resume().
checkSectionClear() after EACH enemy death — don't defer to a timer. The last enemy
in a section often dies mid-animation; check immediately when removeFromWorld() is called.
- Co-op requires 2 hitbox types — in 2-player, both players need their own hitbox types
(PLAYER1_HITBOX, PLAYER2_HITBOX) to avoid friendly fire. Enemies share one ENEMY_HITBOX type.
- Combo reset on player hit — getting hit resets the combo, which feels punishing but is
genre-standard. Consider a 0.5-second grace window after the hit before resetting.
- Food spawns from enemy center — always spawn food at the dead enemy's center position,
not at
getX(), getY() (top-left). Use enemy.getCenter() to avoid food appearing off-center.
1---2name: fxgl-brawler3description: Build a beat 'em up brawler in FXGL — implement a side-scrolling stage that advances when all enemies in a section are defeated, melee hit detection using a short-lived hitbox entity in front of the player, knockback physics impulse on hit, hitstop (brief freeze on contact), an enemy crowd AI that surrounds the player with defined roles (approaching/circling/waiting), a combo counter with timeout, grapple and throw mechanics, food/item pickups that restore health, a boss fight at end of stage, and optional co-op with two players. Use this skill when building a Streets of Rage style brawler, Final Fight clone, beat 'em up, or any game with side-scrolling melee combat against enemy crowds. Triggers on: "brawler", "beat em up", "streets of rage", "melee crowd", "knockback", "combo counter", "hitstop", "grapple", "stage scrolling", "enemy crowd", "beat-em-up".4---56# FXGL Beat 'em Up Brawler78## Stage Architecture910```java11// Stage = sequence of "sections", each with enemy spawns.12// Camera scrolls right when section is cleared.13// Next section spawns when camera reaches the new area.1415private int currentSection = 0;16private final List<SectionData> sections = List.of(17 new SectionData(List.of("thug", "thug", "thug"), 400),18 new SectionData(List.of("thug", "heavy", "thug"), 800),19 new SectionData(List.of("heavy", "heavy", "archer"), 1200),20 new SectionData(List.of("boss"), 1600) // boss section21);2223private void checkSectionClear() {24 if (getGameWorld().getEntitiesByType(EntityType.ENEMY).isEmpty()) {25 currentSection++;26 if (currentSection < sections.size()) {27 advanceCameraToSection(currentSection);28 } else {29 showStageComplete();30 }31 }32}3334private void advanceCameraToSection(int sectionIdx) {35 SectionData section = sections.get(sectionIdx);36 double targetX = section.cameraX();3738 // Animate camera scroll to next area39 animationBuilder()40 .duration(Duration.seconds(1.5))41 .onFinished(() -> spawnSectionEnemies(section))42 .animate(getGameScene().getViewport().xProperty())43 .from(getGameScene().getViewport().getX())44 .to(targetX)45 .buildAndPlay();46}4748private void spawnSectionEnemies(SectionData section) {49 double camX = getGameScene().getViewport().getX();50 int i = 0;51 for (String type : section.enemyTypes()) {52 spawn(type, new SpawnData(camX + getAppWidth() - 100 + i * 80.0,53 GROUND_Y - 40));54 i++;55 }56}57```5859## Player Component6061```java62public class BrawlerPlayerComponent extends Component {63 private PhysicsComponent physics;6465 private static final double MOVE_SPEED = 180.0;66 private double attackCooldown = 0;6768 @Override69 public void onUpdate(double tpf) {70 if (attackCooldown > 0) attackCooldown -= tpf;7172 double dx = 0;73 if (getInput().isHeld(KeyCode.A) || getInput().isHeld(KeyCode.LEFT)) dx -= MOVE_SPEED;74 if (getInput().isHeld(KeyCode.D) || getInput().isHeld(KeyCode.RIGHT)) dx += MOVE_SPEED;7576 // Limit horizontal movement to camera bounds77 double camLeft = getGameScene().getViewport().getX();78 double camRight = camLeft + getAppWidth();79 if ((dx < 0 && entity.getX() <= camLeft + 20) ||80 (dx > 0 && entity.getX() >= camRight - 60)) {81 dx = 0;82 }8384 physics.setVelocityX(dx);85 if (dx > 0) entity.setScaleX(1);86 else if (dx < 0) entity.setScaleX(-1);8788 // Also allow vertical (depth) movement in brawler field89 double dy = 0;90 if (getInput().isHeld(KeyCode.W) || getInput().isHeld(KeyCode.UP)) dy -= MOVE_SPEED * 0.6;91 if (getInput().isHeld(KeyCode.S) || getInput().isHeld(KeyCode.DOWN)) dy += MOVE_SPEED * 0.6;92 physics.setVelocityY(dy);93 }9495 public void attack() {96 if (attackCooldown > 0) return;97 attackCooldown = 0.35;98 spawnAttackHitbox();99 play("sounds/punch.wav");100 }101102 private void spawnAttackHitbox() {103 double facingDir = entity.getScaleX();104 double hbX = entity.getX() + (facingDir > 0 ? entity.getWidth() : -60);105 double hbY = entity.getY() + 10;106107 entityBuilder()108 .type(EntityType.PLAYER_HITBOX)109 .at(hbX, hbY)110 .bbox(BoundingShape.box(60, 40))111 .with(new CollidableComponent(true))112 .set("damage", 15)113 .set("facingDir", facingDir)114 .with(new ExpireCleanComponent(Duration.millis(100)))115 .buildAndAttach();116 }117}118```119120## Hit Detection with Hitstop121122```java123@Override124protected void initPhysics() {125 onCollisionBegin(EntityType.PLAYER_HITBOX, EntityType.ENEMY, (hitbox, enemy) -> {126 int damage = hitbox.getInt("damage");127 double kickDir = hitbox.getDouble("facingDir");128129 // Apply damage130 enemy.getComponent(HPComponent.class).damage(damage);131132 // Knockback133 PhysicsComponent ep = enemy.getComponent(PhysicsComponent.class);134 ep.setVelocityX(kickDir * 350);135 ep.setVelocityY(-150); // small upward pop136137 // Hitstop: freeze both player and enemy for ~5 frames138 applyHitStop(player, Duration.millis(80));139 applyHitStop(enemy, Duration.millis(80));140141 // Combo142 incrementCombo();143144 // Check death145 if (enemy.getComponent(HPComponent.class).isDead()) {146 onEnemyDeath(enemy);147 }148149 play("sounds/hit.wav");150 });151152 onCollisionBegin(EntityType.ENEMY_HITBOX, EntityType.PLAYER, (hitbox, pl) -> {153 pl.getComponent(HPComponent.class).damage(hitbox.getInt("damage"));154 applyHitStop(pl, Duration.millis(100));155 resetCombo();156 });157}158159private void applyHitStop(Entity e, Duration duration) {160 e.getComponent(PhysicsComponent.class).setVelocityX(0);161 e.getComponent(PhysicsComponent.class).setVelocityY(0);162 // Pause AI/player component for duration163 e.getComponent(Component.class).pause();164 runOnce(() -> e.getComponent(Component.class).resume(), duration);165}166167private void onEnemyDeath(Entity enemy) {168 // Random food drop169 if (FXGLMath.random() < 0.25) {170 spawn("food", enemy.getX(), enemy.getY());171 }172 enemy.removeFromWorld();173 checkSectionClear();174}175```176177## Combo Counter178179```java180private int comboCount = 0;181private double comboTimer = 0;182private static final double COMBO_TIMEOUT = 2.0;183184private void incrementCombo() {185 comboCount++;186 comboTimer = COMBO_TIMEOUT;187 showComboText(comboCount);188}189190private void resetCombo() {191 if (comboCount >= 5) {192 // Bonus score for ending a 5+ hit combo193 inc("score", comboCount * 20);194 }195 comboCount = 0;196 hideComboText();197}198199@Override200protected void onUpdate(double tpf) {201 if (comboCount > 0) {202 comboTimer -= tpf;203 if (comboTimer <= 0) resetCombo();204 }205 // ... stage scrolling updates ...206}207```208209## Enemy Crowd AI210211```java212public class BrawlerEnemyComponent extends Component {213 private enum Role { APPROACHING, CIRCLING, WAITING }214 private PhysicsComponent physics;215 private Role role = Role.APPROACHING;216 private double attackTimer = 0;217 private double circleAngle = FXGLMath.random(0, 360);218219 @Override220 public void onUpdate(double tpf) {221 Entity pl = getGameWorld().getSingleton(EntityType.PLAYER);222 double dist = entity.getCenter().distance(pl.getCenter());223224 // Assign role based on how many enemies are already close225 long closeEnemies = getGameWorld().getEntitiesByType(EntityType.ENEMY)226 .stream()227 .filter(e -> e != entity && e.getCenter().distance(pl.getCenter()) < 80)228 .count();229230 role = (closeEnemies >= 2) ? Role.CIRCLING231 : (dist > 200) ? Role.APPROACHING232 : Role.APPROACHING;233234 switch (role) {235 case APPROACHING -> {236 Point2D dir = pl.getCenter().subtract(entity.getCenter()).normalize();237 physics.setVelocityX(dir.getX() * 100);238 physics.setVelocityY(dir.getY() * 60);239240 if (dist < 70) {241 attackTimer += tpf;242 if (attackTimer >= 1.5) {243 attackTimer = 0;244 performAttack();245 }246 }247 }248 case CIRCLING -> {249 // Orbit player250 circleAngle += 60 * tpf;251 double rad = Math.toRadians(circleAngle);252 double targetX = pl.getX() + Math.cos(rad) * 100;253 double targetY = pl.getY() + Math.sin(rad) * 60;254 Point2D toTarget = new Point2D(targetX - entity.getX(), targetY - entity.getY()).normalize();255 physics.setVelocityX(toTarget.getX() * 80);256 physics.setVelocityY(toTarget.getY() * 50);257 }258 }259 }260261 private void performAttack() {262 entityBuilder()263 .type(EntityType.ENEMY_HITBOX)264 .at(entity.getCenter().getX(), entity.getCenter().getY())265 .bbox(BoundingShape.box(50, 30))266 .with(new CollidableComponent(true))267 .set("damage", 8)268 .with(new ExpireCleanComponent(Duration.millis(120)))269 .buildAndAttach();270 play("sounds/enemy_attack.wav");271 }272}273```274275## Food Pickup (Health Restore)276277```java278@Spawns("food")279public Entity newFood(SpawnData data) {280 return entityBuilder(data)281 .type(EntityType.FOOD)282 .view("food_apple.png")283 .bbox(BoundingShape.box(32, 32))284 .with(new CollidableComponent(true))285 .build();286}287288@Override289protected void initPhysics() {290 onCollisionBegin(EntityType.PLAYER, EntityType.FOOD, (pl, food) -> {291 int heal = 30;292 HPComponent hp = pl.getComponent(HPComponent.class);293 hp.setValue(Math.min(hp.getMaxValue(), hp.getValue() + heal));294 food.removeFromWorld();295 play("sounds/eat.wav");296 });297}298```299300## Gotchas301302- **Brawler uses both X and Y movement** — unlike top-down games (no gravity), brawlers move303 on a 2D plane: left/right for horizontal and up/down for depth (Y). Gravity is disabled.304 The player can only fall during knockback (brief Y impulse) but returns to the ground plane.305- **Camera lock during enemy wave** — prevent the camera from scrolling past the next section306 boundary until all enemies are cleared. Use viewport bounds clamping.307- **Hitstop via component pause** — `Component.pause()` stops `onUpdate` for that component.308 This is the cleanest way to freeze entity behavior briefly. Resume with `Component.resume()`.309- **`checkSectionClear()` after EACH enemy death** — don't defer to a timer. The last enemy310 in a section often dies mid-animation; check immediately when `removeFromWorld()` is called.311- **Co-op requires 2 hitbox types** — in 2-player, both players need their own hitbox types312 (PLAYER1_HITBOX, PLAYER2_HITBOX) to avoid friendly fire. Enemies share one ENEMY_HITBOX type.313- **Combo reset on player hit** — getting hit resets the combo, which feels punishing but is314 genre-standard. Consider a 0.5-second grace window after the hit before resetting.315- **Food spawns from enemy center** — always spawn food at the dead enemy's center position,316 not at `getX(), getY()` (top-left). Use `enemy.getCenter()` to avoid food appearing off-center.