Skip to main content Home Creators johannesrabauer fxgl-skills fxgl-bullet-hell
fxgl-bullet-hell Build a bullet hell (danmaku) game in FXGL — create a tiny precise hitbox independent of the player sprite, add a graze sensor for near-miss scoring, script spiral and circular aimed bullet patterns, implement phase-based boss attack scripting, add a screen- clearing bomb that removes all enemy bullets, handle invincibility frames on hit, accelerate and decelerate bullets mid-flight, create homing bullets with delayed lock- on, and efficiently manage 1000+ simultaneous bullets on screen. Use this skill when building a danmaku shooter, pattern-based boss, or any game requiring dense bullet patterns and precise hitbox design.
Jump to install Skills Marketplace Discover and explore AI skills built by the community.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Copy promptShow prompt details A direct command skips the review prompt. Inspect the source before running it.
npx skills add https://github.com/JohannesRabauer/fxgl-skills --skill fxgl-bullet-hellThe command stays on one line. Scroll horizontally to inspect it before copying.
Prefer a local copy? Download the files currently available to SkillsMP.
Download Zip Downloading... More from this repository Add particle systems and visual effects to an FXGL game — configure ParticleEmitter with function-based setters (setVelocityFunction, setAccelerationFunction, setExpireFunction, setScaleFunction, setSpawnPointFunction); use built-in factory emitters (fire, explosion, smoke, rain); apply image-textured particles with multiplyColor / toColor; write custom per-particle physics via setControl; attach TrailParticleComponent for motion trails; apply SlowTimeEffect for bullet-time; apply WobbleEffect for screen-shake; stack multiple effects with EffectComponent; build fireworks displays. Use this skill when adding explosions, fire, smoke, rain, motion trails, impact bursts, bullet-time slow-motion, screen shake, or any particle-based visual polish.
Start a new FXGL project through a bounded Socratic discovery flow, then turn the answers into a docs-first starter specification with ADRs, an arc42-lite architecture summary, and Mermaid use-case diagrams before scaffolding the project itself. Continue using the same questioning approach for initialization choices, and require documentation updates plus automated tests for every implemented step.
Build a Breakout or Arkanoid style game in FXGL — control a paddle horizontally, keep a ball at constant speed, bounce it off walls, paddle, and bricks, load brick layouts from level data, angle the reflection based on paddle hit position, handle lives and bottom-drain loss, spawn falling power-ups, and finish the level when all breakable bricks are destroyed.
Related occupations SOC
Based on SOC occupation classification
name fxgl-bullet-hell description Build a bullet hell (danmaku) game in FXGL — create a tiny precise hitbox independent of the player sprite, add a graze sensor for near-miss scoring, script spiral and circular aimed bullet patterns, implement phase-based boss attack scripting, add a screen- clearing bomb that removes all enemy bullets, handle invincibility frames on hit, accelerate and decelerate bullets mid-flight, create homing bullets with delayed lock- on, and efficiently manage 1000+ simultaneous bullets on screen. Use this skill when building a danmaku shooter, pattern-based boss, or any game requiring dense bullet patterns and precise hitbox design.
triggers ["bullet hell","danmaku","spiral pattern","bullet pattern","graze","bomb","hitbox smaller than sprite","homing bullet","1000 bullets","dense bullets","boss pattern"] compatibility Java 17+, FXGL 21.x
category fxgl/game-types tags ["fxgl","java","javafx","game-types","bullet","hell"] metadata {"author":"fxgl-skills","version":"1.0","fxgl-version":"21.1"} allowed-tools ["Read","Write","Edit","Bash"]
FXGL Bullet Hell
Player: Tiny Hitbox + Graze Sensor
@Spawns("player")
public Entity newPlayer (SpawnData data) {
PhysicsComponent hitboxPhysics = new PhysicsComponent ();
hitboxPhysics.setBodyType(BodyType.DYNAMIC);
hitboxPhysics.setFixedRotation(true );
FixtureDef hitFD = new FixtureDef ();
hitFD.setFriction(0 );
hitFD.setDensity(1 );
hitboxPhysics.addFixtureDef(hitFD);
Entity player = entityBuilder(data)
.type(EntityType.PLAYER)
.view("player_ship.png" )
.bbox(BoundingShape.circle(4 ))
.with(hitboxPhysics)
.with(new CollidableComponent (true ))
.with(new BulletHellPlayerComponent ())
.build();
return player;
}
private Entity grazeEntity;
@Override
protected void initGame () {
grazeEntity = entityBuilder()
.type(EntityType.GRAZE_SENSOR)
.at(player.getX(), player.getY())
.bbox(BoundingShape.circle(18 ))
.with( ( ))
.buildAndAttach();
}
{
grazeEntity.setPosition(player.getPosition());
}
new
CollidableComponent
true
@Override
protected
void
onUpdate
(double tpf)
Player Movement and Focused Mode public class BulletHellPlayerComponent extends Component {
private static final double NORMAL_SPEED = 250.0 ;
private static final double FOCUSED_SPEED = 100.0 ;
@Override
public void onUpdate (double tpf) {
boolean focused = getInput().isHeld(KeyCode.SHIFT);
double speed = focused ? FOCUSED_SPEED : NORMAL_SPEED;
double dx = 0 , dy = 0 ;
if (getInput().isHeld(KeyCode.LEFT)) dx -= 1 ;
if (getInput().isHeld(KeyCode.RIGHT)) dx += 1 ;
if (getInput().isHeld(KeyCode.UP)) dy -= 1 ;
if (getInput().isHeld(KeyCode.DOWN)) dy += 1 ;
if (dx != 0 && dy != 0 ) { dx *= 0.7071 ; dy *= 0.7071 ; }
entity.getComponent(PhysicsComponent.class).setVelocityX(dx * speed);
entity.getComponent(PhysicsComponent.class).setVelocityY(dy * speed);
double px = Math.max(FIELD_X, Math.min(FIELD_X + FIELD_W - 8 , entity.getX()));
double py = Math.max(FIELD_Y, Math.min(FIELD_Y + FIELD_H - 8 , entity.getY()));
entity.setPosition(px, py);
}
}
Collision Handlers @Override
protected void initPhysics () {
onCollisionBegin(EntityType.PLAYER, EntityType.ENEMY_BULLET, (player, bullet) -> {
if (getb("invincible" )) return ;
bullet.removeFromWorld();
onPlayerDeath();
});
Set<Entity> grazedBullets = new HashSet <>();
onCollisionBegin(EntityType.GRAZE_SENSOR, EntityType.ENEMY_BULLET, (sensor, bullet) -> {
if (!grazedBullets.contains(bullet)) {
grazedBullets.add(bullet);
inc("grazeScore" , 10 );
inc("bombCharge" , 1 );
play("sounds/graze.wav" );
}
});
onCollisionEnd(EntityType.GRAZE_SENSOR, EntityType.ENEMY_BULLET, (sensor, bullet) ->
grazedBullets.remove(bullet));
}
Spiral Pattern public class SpiralShooterComponent extends Component {
private double angle = 0 ;
private double timer = 0 ;
private static final double FIRE_INTERVAL = 0.05 ;
private static final int ARMS = 3 ;
@Override
public void onUpdate (double tpf) {
timer += tpf;
if (timer < FIRE_INTERVAL) return ;
timer = 0 ;
for (int arm = 0 ; arm < ARMS; arm++) {
double armAngle = angle + arm * (360.0 / ARMS);
double rad = Math.toRadians(armAngle);
Point2D dir = new Point2D (Math.cos(rad), Math.sin(rad));
spawnEnemyBullet(entity.getCenter(), dir, 180 );
}
angle = (angle + 3 ) % 360 ;
}
}
Aimed Pattern + Delay public class AimedBurstComponent extends Component {
private double burstTimer = 0 ;
private static final double BURST_INTERVAL = 2.0 ;
private static final int BURST_COUNT = 5 ;
@Override
public void onUpdate (double tpf) {
burstTimer += tpf;
if (burstTimer < BURST_INTERVAL) return ;
burstTimer = 0 ;
fireAimedBurst();
}
private void fireAimedBurst () {
Point2D playerPos = getGameWorld().getSingleton(EntityType.PLAYER).getCenter();
Point2D dir = playerPos.subtract(entity.getCenter()).normalize();
double baseAngle = Math.toDegrees(Math.atan2(dir.getY(), dir.getX()));
for (int i = 0 ; i < BURST_COUNT; i++) {
int fi = i;
runOnce(() -> {
double rad = Math.toRadians(baseAngle);
spawnEnemyBullet(entity.getCenter(),
new Point2D (Math.cos(rad), Math.sin(rad)), 200 );
}, Duration.millis(i * 80L ));
}
}
}
Homing Bullet (Delayed Lock-On) public class HomingBulletComponent extends Component {
private Point2D velocity;
private double homingDelay;
private double elapsed = 0 ;
private static final double TURN_SPEED = 180 ;
public HomingBulletComponent (Point2D initialDir, double speed, double homingDelay) {
this .velocity = initialDir.multiply(speed);
this .homingDelay = homingDelay;
}
@Override
public void onUpdate (double tpf) {
elapsed += tpf;
if (elapsed > homingDelay) {
Point2D toPlayer = getGameWorld()
.getSingleton(EntityType.PLAYER)
.getCenter()
.subtract(entity.getCenter())
.normalize();
double currentAngle = Math.toDegrees(Math.atan2(velocity.getY(), velocity.getX()));
double targetAngle = Math.toDegrees(Math.atan2(toPlayer.getY(), toPlayer.getX()));
double delta = ((targetAngle - currentAngle + 540 ) % 360 ) - 180 ;
double maxTurn = TURN_SPEED * tpf;
double newAngle = currentAngle + Math.signum(delta) * Math.min(Math.abs(delta), maxTurn);
double speed = velocity.magnitude();
double rad = Math.toRadians(newAngle);
velocity = new Point2D (Math.cos(rad) * speed, Math.sin(rad) * speed);
}
entity.translate(velocity.getX() * tpf, velocity.getY() * tpf);
}
}
Bomb — Screen Clear private void useBomb () {
if (geti("bombs" ) <= 0 ) return ;
inc("bombs" , -1 );
new ArrayList <>(getGameWorld().getEntitiesByType(EntityType.ENEMY_BULLET))
.forEach(Entity::removeFromWorld);
set("invincible" , true );
runOnce(() -> set("invincible" , false ), Duration.seconds(2 ));
Entity flash = entityBuilder()
.view(new Rectangle (getAppWidth(), getAppHeight(), Color.WHITE))
.zIndex(100 )
.buildAndAttach();
animationBuilder()
.duration(Duration.seconds(0.5 ))
.fadeOut(flash)
.onFinished(() -> flash.removeFromWorld())
.buildAndPlay();
play("sounds/bomb.wav" );
}
Invincibility Frames @Override
protected void initGameVars (Map<String, Object> vars) {
vars.put("lives" , 3 );
vars.put("bombs" , 3 );
vars.put("invincible" , false );
vars.put("grazeScore" , 0 );
vars.put("bombCharge" , 0 );
}
private void onPlayerDeath () {
if (geti("lives" ) <= 0 ) {
showGameOver();
return ;
}
inc("lives" , -1 );
inc("bombs" , 1 );
set("invincible" , true );
animationBuilder()
.duration(Duration.millis(100 ))
.repeat(20 )
.autoReverse(true )
.fade(player).from(1.0 ).to(0.1 )
.buildAndPlay();
runOnce(() -> set("invincible" , false ), Duration.seconds(2 ));
}
Performance: Managing 1000+ Bullets
@Spawns("enemyBullet")
public Entity newEnemyBullet (SpawnData data) {
Point2D dir = data.get("dir" );
double speed = data.getOrDefault("speed" , 200.0 );
PhysicsComponent physics = new PhysicsComponent ();
physics.setBodyType(BodyType.DYNAMIC);
physics.setFixedRotation(true );
FixtureDef fd = new FixtureDef ();
fd.setSensor(true );
physics.addFixtureDef(fd);
return entityBuilder(data)
.type(EntityType.ENEMY_BULLET)
.view(new Circle (3 , Color.CRIMSON))
.bbox(BoundingShape.circle(3 ))
.with(physics)
.with(new CollidableComponent (true ))
.with(new BulletMovementComponent (dir, speed))
.with(new ExpireCleanComponent (Duration.seconds(8 )))
.build();
}
public class BulletMovementComponent extends Component {
private final Point2D dir;
private final double speed;
@Override
public void onUpdate (double tpf) {
entity.translate(dir.getX() * speed * tpf, dir.getY() * speed * tpf);
if (entity.getX() < -20 || entity.getX() > getAppWidth() + 20
|| entity.getY() < -20 || entity.getY() > getAppHeight() + 20 ) {
entity.removeFromWorld();
}
}
}
Boss Phase State Machine public class BossComponent extends Component {
private HPComponent hp;
private int phase = 1 ;
private double attackTimer = 0 ;
@Override
public void onUpdate (double tpf) {
double hpRatio = (double ) hp.getValue() / hp.getMaxValue();
int newPhase = hpRatio > 0.6 ? 1 : hpRatio > 0.3 ? 2 : 3 ;
if (newPhase != phase) {
phase = newPhase;
attackTimer = 0 ;
play("sounds/phase_change.wav" );
}
attackTimer += tpf;
double interval = switch (phase) {
case 1 -> 2.5 ;
case 2 -> 1.5 ;
case 3 -> 0.8 ;
default -> 2.5 ;
};
if (attackTimer >= interval) {
attackTimer = 0 ;
switch (phase) {
case 1 -> fireAimedBurst();
case 2 -> { fireSpiral(); fireAimedBurst(); }
case 3 -> { fireSpiral(); fireRing(24 ); }
}
}
}
}
Gotchas
Sensor bodies for bullets, not solid bodies — solid bullets at high density interact
with each other's physics, causing unpredictable velocity changes. Sensors have overlap
detection only with zero physics response.
Player hitbox must be independent of view — FXGL uses the bbox for physics and
collision, NOT the view image size. A 4px-radius circle bbox on a 48px sprite gives proper
bullet hell hitboxes.
BulletMovementComponent is faster than ProjectileComponent at scale — ProjectileComponent
uses Box2D velocity. A custom translate in onUpdate is cheaper for 1000+ bullets.
Graze sensor must track which bullets were already grazed — onCollisionBegin fires
every frame the contact persists in FXGL. Use a Set<Entity> to deduplicate.
new ArrayList<>() before removing from world — calling entity.removeFromWorld()
while iterating getEntitiesByType() causes ConcurrentModificationException. Always copy first.
Bomb clears bullets in next frame — bullets destroyed this frame still process one more
onUpdate and collision check. The invincibility window prevents the player dying in that gap.
Boss movement should use setPosition not physics velocity — boss patterns are typically
scripted (sine wave, figure-8) and are most predictable when position is computed analytically.