Build a roguelike or roguelite game in FXGL — structure runs with a full state reset on death, save only meta-progression permanently, generate a new procedural dungeon each run with DungeonGenerator, implement weighted RNG loot tables, show 3-choice item offer screens, track run statistics (kills, gold, floor depth), gate boss fights at milestone floors, implement item synergies where items modify each other's effects, add curse mechanics as item downsides, and integrate a prestige/unlock system for permanent cross- run improvements. Use this skill when building a roguelike, roguelite, dungeon crawler, run-based game, or any permadeath game with procedural content.
Instrucciones de origen · Vista previa de solo lectura
name
fxgl-roguelike
description
Build a roguelike or roguelite game in FXGL — structure runs with a full state reset on death, save only meta-progression permanently, generate a new procedural dungeon each run with DungeonGenerator, implement weighted RNG loot tables, show 3-choice item offer screens, track run statistics (kills, gold, floor depth), gate boss fights at milestone floors, implement item synergies where items modify each other's effects, add curse mechanics as item downsides, and integrate a prestige/unlock system for permanent cross- run improvements. Use this skill when building a roguelike, roguelite, dungeon crawler, run-based game, or any permadeath game with procedural content.
// Run save (wiped on death) — stored in "run_save"// Meta save (permanent) — stored in "meta_save"// Never mix them. On death:// 1. Update meta save with run stats// 2. Delete/reset run save// 3. Show death screen, offer return to main menu
Run Initialization
@OverrideprotectedvoidinitGameVars(Map<String, Object> vars) {
// Run-scoped (reset on death)
vars.put("floor", 1);
vars.put("gold", 0);
vars.put("kills", 0);
vars.put("runScore", 0);
vars.put("playerHP", 100);
vars.put("playerMaxHP", 100);
vars.put("playerATK", 10);
vars.put("playerDEF", 0);
// Run item flags
vars.put("hasFireSword", false);
vars.put("hasIceShield", false);
vars.put("hasVampirism", false);
}
privatevoidstartNewRun() {
// Reset all run-scoped variables to defaults
initGameVars(newHashMap<>()); // conceptually — in practice re-init the game
set(, System.currentTimeMillis());
generateFloor(geti());
}
"runSeed"
"floor"
Procedural Floor Generation
privatelong runSeed;
privatevoidgenerateFloor(int floorNumber) {
// Clear existing entities
getGameWorld().getEntitiesCopy().forEach(Entity::removeFromWorld);
// Seed per-floor: deterministic from run seed + floorlongfloorSeed= runSeed + floorNumber * 7919L;
DungeonConfigconfig=newDungeonConfig()
.gridWidth(40 + floorNumber * 2)
.gridHeight(40 + floorNumber * 2)
.minRoomSize(4)
.maxRoomSize(10 + floorNumber)
.maxRooms(8 + floorNumber * 2)
.seed(floorSeed);
DungeonGeneratorgen=newDungeonGenerator(config);
Grid2D<DungeonCell> dungeon = gen.generate();
spawnDungeonTiles(dungeon);
placeEntities(gen.getRooms(), floorNumber);
// Rebuild A* grid after level load
astarGrid = AStarGrid.fromWorld(getGameWorld(), TILE, TILE);
}
privatevoidplaceEntities(List<Room> rooms, int floor) {
Randomrng=newRandom(runSeed + floor * 13L);
// Player starts in room 0Roomstart= rooms.get(0);
player.setPosition(start.getCenterX() * TILE, start.getCenterY() * TILE);
// Boss in last roomRoomboss= rooms.get(rooms.size() - 1);
spawn("boss_" + getBossTypeForFloor(floor),
boss.getCenterX() * TILE, boss.getCenterY() * TILE);
// Enemies and items in middle roomsfor (inti=1; i < rooms.size() - 1; i++) {
Roomroom= rooms.get(i);
intenemyCount= rng.nextInt(3) + 1 + floor / 2;
for (inte=0; e < enemyCount; e++) {
spawn("enemy_" + getRandomEnemyType(floor, rng),
room.getX() * TILE + rng.nextInt(room.getWidth()) * TILE,
room.getY() * TILE + rng.nextInt(room.getHeight()) * TILE);
}
// 30% chance for a chestif (rng.nextDouble() < 0.3) {
spawn("chest", room.getCenterX() * TILE, room.getCenterY() * TILE);
}
}
// Stairs to next floor in boss room (unlock after boss dies)
spawn("stairs_locked", boss.getCenterX() * TILE + TILE, boss.getCenterY() * TILE);
}
// Items check for other items to modify their behaviorpublicvoidapplyItem(String itemId) {
switch (itemId) {
case"sword_fire" -> {
set("hasFireSword", true);
inc("playerATK", 5);
// Synergy: fire sword + ice shield = steam explosion on hitif (getb("hasIceShield")) {
set("hasSteamExplosion", true);
showSynergyMessage("Fire + Ice = Steam Explosion!");
}
}
case"ice_shield" -> {
set("hasIceShield", true);
inc("playerDEF", 8);
if (getb("hasFireSword")) {
set("hasSteamExplosion", true);
showSynergyMessage("Fire + Ice = Steam Explosion!");
}
}
case"vampire_ring" -> {
set("hasVampirism", true);
// Vampirism lifesteal is handled in combat damage resolution
}
case"cursed_blade" -> {
// Curse: high damage but player takes 10% damage each floor
set("hasCursedBlade", true);
inc("playerATK", 20);
set("curseDamagePerFloor", 10);
showCurseWarning("Cursed Blade accepted. You will suffer for its power.");
}
}
}
// In combat damage resolution:privatevoidonPlayerDealsHit(int damage) {
if (getb("hasVampirism")) {
intheal= Math.max(1, damage / 5);
inc("playerHP", heal);
set("playerHP", Math.min(geti("playerHP"), geti("playerMaxHP")));
}
if (getb("hasSteamExplosion")) {
// Splash damage to nearby enemies
spawnExplosion(player.getCenter(), 80, damage / 2);
}
}
Permadeath and Run End
privatevoidonPlayerDeath() {
getGameController().pauseEngine();
// Record run stats to permanent meta save
saveRunStats();
checkMetaUnlocks();
// Show death screen
getDialogService().showMessageBox(
"You died on floor " + geti("floor") + "\n" +
"Kills: " + geti("kills") + " | Gold: " + geti("gold"),
() -> returnToMainMenu()
);
}
privatevoidsaveRunStats() {
// Load existing meta save or create new
getSaveLoadService().load("meta_save");
// Update lifetime stats
inc("totalKills", geti("kills"));
inc("totalGold", geti("gold"));
inc("totalRuns", 1);
inc("metaCurrency", geti("floor") * geti("kills"));
intdeepest= Math.max(geti("deepestFloor"), geti("floor"));
set("deepestFloor", deepest);
getSaveLoadService().saveAndForget("meta_save");
}
privatevoidcheckMetaUnlocks() {
if (geti("totalKills") >= 100 && !getb("unlocked_knight")) {
set("unlocked_knight", true);
showUnlockMessage("New character unlocked: The Knight!");
}
}
Floor Descent
// Player steps on stairs:
onCollisionBegin(EntityType.PLAYER, EntityType.STAIRS, (player, stairs) -> {
if (stairs.getString("state").equals("locked")) return;
inc("floor", 1);
play("sounds/stairs.wav");
generateFloor(geti("floor"));
});
// Unlock stairs when boss dies:privatevoidonBossDeath(Entity boss) {
getGameWorld().getEntitiesByType(EntityType.STAIRS_LOCKED)
.forEach(e -> { e.set("state", "unlocked"); /* update view */ });
inc("kills", 1);
spawnLoot(boss.getCenter(), 3); // boss drops 3 items
showItemOffer();
}
Gotchas
Two separate save files — run state and meta state must never be in the same DataFile.
Losing a run should never affect meta unlocks, and vice versa. Keep them strictly separated.
Floor seed derived from run seed + floor number — this lets the player return to the
exact same floor layout if they die and replay (for debugging), while still making each run unique.
Item offer RNG must be seeded independently — if item offers use the same RNG as floor
generation, opening the offer at different times gives different items. Seed item RNG with
(runSeed + floor * 13L) for consistent per-floor offers.
Synergy detection runs at item pickup time — don't check synergies on every frame.
Check when a new item is applied and set a boolean flag. Combat code reads the flag.
getGameWorld().getEntitiesCopy() when clearing between floors — getEntities() returns
a live list; removing from it while iterating throws ConcurrentModificationException.
Cursor/Blessed items affect loot table weight — if items give "+luck", apply that bonus
to all weights in the loot table roll. Don't increase drop chance only for rare items.
Permadeath save cleanup — after death, explicitly call getSaveLoadService().deleteSave("run_save")
so a game crash can't leave a corrupted run save that bypasses permadeath.