Skip to content

A deep dive into Pixel Dungeon's long-standing spawning issues #1063

Description

@gigalh128

The way Shattered Pixel Dungeon handles placing characters in the dungeon is inherently flawed, in a way that allows for fascinating exploits and causes many bugs. This is not Shattered’s fault: the issue stems all the way back from how Vanilla PD handled this task -- assuming open space exists. It would be great to see these fixed in Shattered because a lot of mods are also inheriting these same bugs!

There are two main issues: invalid infinite loops and short sighted space availability checks.

Code issue examples

Invalid infinite loop

public int fallCell( boolean fallIntoPit ) {
if (fallIntoPit) {
for (Room room : rooms) {
if (room instanceof PitRoom) {
int result;
do {
result = pointToCell(room.random());
} while (traps.get(result) != null
|| findMob(result) != null
|| heaps.get(result) != null);
return result;
}
}
}
return super.fallCell( false );
}

If you drop an item on each cell in a pit room, then jump into it, the game will infinitely loop. Alternatively, you can just throw a stone of flock into the room. Interestingly, you can use this to get a death by falling on floor 1. For actions like these, there needs to be some kind of failsafe implemented because it's within the player's control to cause failures.

Short-Sighted Space Availability Check

moving.pos = returnPos;
for(int i : PathFinder.NEIGHBOURS8){
if (Actor.findChar(moving.pos+i) == null
&& Dungeon.level.passable[moving.pos + i]
&& (!Char.hasProp(moving, Char.Property.LARGE) || Dungeon.level.openSpace[moving.pos + i])){
moving.pos += i;
moving.sprite.point(moving.sprite.worldToCamera(moving.pos));
break;
}
}

This code makes the beacon of returning hands down one of the best items. Because it doesn't do anything special if your original location + all 8 neighbors fail, it gives you an easy way to spawn inside of your enemies and make use of char stacking properties. Of course, this is probably not the intended behavior, so the game needs to also check if all cases fail and act accordingly.

Locations and Descriptions of Failure

Please feel free to double check these things. If I can't find something in the code, I'll do my best to describe how it happens with video footage.
Many of these scenarios can be done in game with enough ring of wealth/wand of regrowth/blooming or lucky weapon patience. I could also be missing some areas.

Invalid Infinite Loops

Level RandomRespawnCell Methods

  • public int randomRespawnCell( Char ch ) {
    int cell;
    do {
    cell = Random.Int( length() );
    } while ((Dungeon.level == this && heroFOV[cell])
    || !passable[cell]
    || (Char.hasProp(ch, Char.Property.LARGE) && !openSpace[cell])
    || Actor.findChar( cell ) != null);
    return cell;
    }
  • while (true) {
    if (++count > 30) {
    return -1;
    }
    Room room = randomRoom( StandardRoom.class );
    if (room == null || room == roomEntrance) {
    continue;
    }
    cell = pointToCell(room.random(1));
    if (!heroFOV[cell]
    && Actor.findChar( cell ) == null
    && passable[cell]
    && !solid[cell]
    && (!Char.hasProp(ch, Char.Property.LARGE) || openSpace[cell])
    && room.canPlaceCharacter(cellToPoint(cell), this)
    && cell != exit()) {
    return cell;
    }
    }

    Curiously, there is actually a failsafe here, so flooding a normal level with sheep and jumping down will instead cause the game to crash instead of infinitely looping.
  • int cell;
    do {
    cell = entrance() + PathFinder.NEIGHBOURS8[Random.Int(8)];
    } while (!passable[cell]
    || (Char.hasProp(ch, Char.Property.LARGE) && !openSpace[cell])
    || Actor.findChar(cell) != null);
    return cell;
  • Every Boss Level is bugged. These ones are particularly frail because they only encompass a 9 tile area and can infinite loop with just one stone of flock at spawn. Yes, the sewer boss level is slightly less frail, but the same idea still holds.

Level fallCell Methods

DM 300 Spawning

do {
boss.pos = pointToCell(Random.element(mainArena.getPoints()));
} while (!openSpace[boss.pos] || map[boss.pos] == Terrain.EMPTY_SP || Actor.findChar(boss.pos) != null);

Ankh Respawn Placement

int invPos = Dungeon.hero.pos;
int tries = 0;
do {
Dungeon.hero.pos = level.randomRespawnCell(Dungeon.hero);
tries++;
//prevents spawning on traps or plants, prefers farther locations first
} while (level.traps.get(Dungeon.hero.pos) != null
|| (level.plants.get(Dungeon.hero.pos) != null && tries < 500)
|| level.trueDistance(invPos, Dungeon.hero.pos) <= 30 - (tries/10));
//directly trample grass
if (level.map[Dungeon.hero.pos] == Terrain.HIGH_GRASS || level.map[Dungeon.hero.pos] == Terrain.FURROWED_GRASS){
level.map[Dungeon.hero.pos] = Terrain.GRASS;
}

This one actually has two issues embedded since RandomRespawnCell is bugged.

Short-Sighted Checks

Usually happens because Neighbours8 isn't rigorous enough.

Newborn Elemental Spawning

ArrayList<Integer> candidates = new ArrayList<>();
for (int n : PathFinder.NEIGHBOURS8) {
int cell = ritualPos + n;
if ((Dungeon.level.passable[cell] || Dungeon.level.avoid[cell]) && Actor.findChar( cell ) == null) {
candidates.add( cell );
}
}
if (candidates.size() > 0) {
elemental.pos = Random.element( candidates );
} else {
elemental.pos = ritualPos;
}
} else {
elemental.pos = ritualPos;
}

I see there is a failsafe, but it seems rather exploitable. If that's ok with you, then I don't see a problem either.

Fist Spawning

if (!Dungeon.isChallenged(Challenges.STRONGER_BOSSES)
&& (Actor.findChar(targetPos) == null || Actor.findChar(targetPos) instanceof Sheep)){
fist.pos = targetPos;
} else if (Actor.findChar(targetPos-1) == null || Actor.findChar(targetPos-1) instanceof Sheep){
fist.pos = targetPos-1;
} else if (Actor.findChar(targetPos+1) == null || Actor.findChar(targetPos+1) instanceof Sheep){
fist.pos = targetPos+1;
} else if (Actor.findChar(targetPos) == null || Actor.findChar(targetPos) instanceof Sheep){
fist.pos = targetPos;
}
if (Actor.findChar(fist.pos) instanceof Sheep){
Actor.findChar(fist.pos).die(null);
}

What if I used bees? Then the fist spawns in the wall.

Yog Spawning

YogDzewa boss = new YogDzewa();
boss.pos = exit() + width*3;
GameScene.add( boss );

The player can spawn into Yog with a stone of blink.

Necromancer minion spawning

  • int pushPos = pos;
    for (int c : PathFinder.NEIGHBOURS8) {
    if (Actor.findChar(summoningPos + c) == null
    && Dungeon.level.passable[summoningPos + c]
    && (Dungeon.level.openSpace[summoningPos + c] || !hasProp(Actor.findChar(summoningPos), Property.LARGE))
    && Dungeon.level.trueDistance(pos, summoningPos + c) > Dungeon.level.trueDistance(pos, pushPos)) {
    pushPos = summoningPos + c;
    }
    }
    //push enemy, or wait a turn if there is no valid pushing position
    if (pushPos != pos) {
    Char ch = Actor.findChar(summoningPos);
    Actor.addDelayed( new Pushing( ch, ch.pos, pushPos ), -1 );
    ch.pos = pushPos;
    Dungeon.level.occupyCell(ch );
    } else {
    Char blocker = Actor.findChar(summoningPos);
    if (blocker.alignment != alignment){
    blocker.damage( Random.NormalIntRange(2, 10), this );
    }
    spend(TICK);
    return;
    }
  • && Dungeon.level.passable[summoningPos + c]
    && (Dungeon.level.openSpace[summoningPos + c] || !hasProp(Actor.findChar(summoningPos), Property.LARGE))
    && Dungeon.level.trueDistance(pos, summoningPos + c) > Dungeon.level.trueDistance(pos, pushPos)) {
    pushPos = summoningPos + c;
    }
    }
    //push enemy, or wait a turn if there is no valid pushing position
    if (pushPos != pos) {
    Char ch = Actor.findChar(summoningPos);
    Actor.addDelayed( new Pushing( ch, ch.pos, pushPos ), -1 );
    ch.pos = pushPos;
    Dungeon.level.occupyCell(ch );
    } else {
    Char blocker = Actor.findChar(summoningPos);
    if (blocker.alignment != alignment){
    blocker.damage( Random.NormalIntRange(2, 10), this );
    }
    spend(TICK);
    return;
    }
    }
    summoning = firstSummon = false;

The necromancer/spectral necromancer can push immovable mobs. Technically not as useful as spawning back into the game since you can't use it to arbitrarily move immovable mobs. It can only move immovable things that you spawn in (e.g. wards, dm201), but I doubt this was intended because you can't pull your own wards with ethereal chains.

Returning to a depth

for(Mob m : level.mobs){
if (m.pos == hero.pos){
//displace mob
for(int i : PathFinder.NEIGHBOURS8){
if (Actor.findChar(m.pos+i) == null && level.passable[m.pos + i]){
m.pos += i;
break;
}
}
}
}

Two issues here. One, it doesn't check if the mob is immovable, so you can use this method to move immovable mobs. Second is that this check does nothing if you fill the 3x3 area with mobs (most likely suspect being sheep).

Teleporting via Beacon of Returning

moving.pos = returnPos;
for(int i : PathFinder.NEIGHBOURS8){
if (Actor.findChar(moving.pos+i) == null
&& Dungeon.level.passable[moving.pos + i]
&& (!Char.hasProp(moving, Char.Property.LARGE) || Dungeon.level.openSpace[moving.pos + i])){
moving.pos += i;
moving.sprite.point(moving.sprite.worldToCamera(moving.pos));
break;
}
}

Also allows you to move immovable mobs.

Teleporting via Warp Beacon

Char toPush = Char.hasProp(existing, Char.Property.IMMOVABLE) ? hero : existing;
ArrayList<Integer> candidates = new ArrayList<>();
for (int n : PathFinder.NEIGHBOURS8) {
int cell = tracker.pos + n;
if (!Dungeon.level.solid[cell] && Actor.findChar( cell ) == null
&& (!Char.hasProp(toPush, Char.Property.LARGE) || Dungeon.level.openSpace[cell])) {
candidates.add( cell );
}
}
Random.shuffle(candidates);
if (!candidates.isEmpty()){
Actor.addDelayed( new Pushing( toPush, toPush.pos, candidates.get(0) ), -1 );
toPush.pos = candidates.get(0);
Dungeon.level.occupyCell(toPush);
hero.next();
}
}

You get to spawn into the mob if there's no space in the 3x3 area. Combined with the beacon of returning, you can use this to move any immovable mob to an arbitrary location on the m

Implications in Practice

Here are some bugs caused by these issues that you may not have seen.

  • Use a stone of flock on the stairs in tengu's room and then cast a reclaim trap imbued with a guardian trap. You game will infinitely loop.
  • You can softlock yourself in DM300's level by preventing it from spawning via a mass of woolly bombs. (spawned in the items via scroll of debug to save time and applied various buffs to make things more visible, but otherwise no major deviation from base Shattered)
    Video: https://drive.google.com/file/d/142n_duVBLAhCIyba_v2v82klxpTHIy5R/view?usp=sharing
    Save file: dm300-bomb-fire.zip
    ap.
  • You can use the ankh to hardlock yourself into a situation where you aren't even allowed to lose by throwing a stone of flock onto the ascending stairs in any boss room.
  • You can die by falling on floor 1 (fell from the city above, perhaps?).
  • Various exploits for beating bosses using the warp beacon and character stacking.
  • The mage with warp beacon can move immovable mobs anywhere on stage.

There are probably more bugs that I forgot about.

Plea for a Proper Fix

Please do not fix this by hardcoding cases for things like bees or sheep. It isn't effective because you can replace it with nearly any other mob, provided you have the right setup. Want a random mob from anywhere? Get a curse of multiplicity and a trusty trap reclaim imbued with distortion. Making a general check for dungeon mobs? Not enough. What if I summoned an army of illusions by collecting enough scrolls via ring of wealth. What if I used ratmorgrify to flood the level? What about the wand of warding? What if I had the patience to flood the level with super mimics via my cursed wand?

I hope this illustrates why you shouldn't just hardcode fixes for this class of issues this time around. It is simply too fragile. We need a strong general fix that applies at the fallCell/randomRespawn level. This is not just for Shattered's good, but all of those who may use it. People are relying on your codebase to be solid. The worst thing that can come out of hardcoding would be teaching new programmers that "bandaging problems" is ok.

Conclusion

Thank you for taking the time to read this monolith of a report. Took me about a month of research to compile and write up these findings. I would have liked to make suggestions on how to handle this issue, but in the spirit of Shattered not taking pull requests, you probably would actively try not to do anything I suggest since it would be very code focused, so I'll stay quiet. Hope you find a good solution to this long-standing issue of spawnblocking in Pixel Dungeon.

Metadata

Metadata

Assignees

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions