Skip to content

Custom level generation#750

Open
thicco-catto wants to merge 36 commits intoTeamREPENTOGON:mainfrom
thicco-catto:level-gen
Open

Custom level generation#750
thicco-catto wants to merge 36 commits intoTeamREPENTOGON:mainfrom
thicco-catto:level-gen

Conversation

@thicco-catto
Copy link
Collaborator

Added a new callback and a couple functions that allow for mods to generate custom floors.

MC_PRE_GENERATE_DUNGEON
Called before level::generate_dungeon is called, which places all the rooms in a floor. Passes an RNG.
Return true to cancel vanilla floor generation.

Level:ResetRoomList(bool)
Needs to be called before placing any rooms in the custom level.
The bool is the same as the one in level::init. Testing it so far, setting it to true makes it work all the time.

Level:SetLastBossRoomListIndex(int)
Also needs to be called after creating the custom floor, since not setting it crashes the game when continuing.
Doesn't actually need to be set to a boss room for the game to not crash, but I haven't tested any other side effects apart from boss rooms not spawning the trapdoor if they are not the last.

Sample
image

local TestMod = RegisterMod("Test Mod", 1)

local function place_at(room, col, row, seed)
    local level = Game():GetLevel()

    local entry = Isaac.LevelGeneratorEntry()
    entry:SetAllowedDoors(15)
    entry:SetColIdx(col)
    entry:SetLineIdx(row)

    level:PlaceRoom(entry, room, seed)
end

local function get_normal_room(seed)
    return RoomConfigHolder.GetRandomRoom(seed, false, StbType.BASEMENT, RoomType.ROOM_DEFAULT, RoomShape.ROOMSHAPE_1x1, -1, -1, 0, 10, 15)
end

TestMod:AddCallback(ModCallbacks.MC_PRE_GENERATE_DUNGEON, function (_, rng)
    local level = Game():GetLevel()
    level:ResetRoomList(true)

    print(rng:GetSeed())

    local start_room = RoomConfigHolder.GetRandomRoom(rng:Next(), false, StbType.SPECIAL_ROOMS, RoomType.ROOM_DEFAULT, RoomShape.ROOMSHAPE_1x1, 2, 2)
    local big_room = RoomConfigHolder.GetRandomRoom(rng:Next(), false, StbType.BASEMENT, RoomType.ROOM_DEFAULT, RoomShape.ROOMSHAPE_2x2, -1, -1, 0, 10, 255)
    local boss_room = RoomConfigHolder.GetRandomRoom(rng:Next(), false, 0, RoomType.ROOM_BOSS, RoomShape.ROOMSHAPE_1x1)

    place_at(start_room, 6, 6, rng:Next())
    place_at(get_normal_room(rng:Next()), 7, 6, rng:Next())
    place_at(boss_room, 8, 6, rng:Next())
    place_at(get_normal_room(rng:Next()), 6, 7, rng:Next())
    place_at(get_normal_room(rng:Next()), 6, 8, rng:Next())
    place_at(get_normal_room(rng:Next()), 7, 8, rng:Next())
    place_at(get_normal_room(rng:Next()), 8, 7, rng:Next())
    place_at(big_room, 8, 8, rng:Next())

    level:SetLastBossRoomListIndex(2)

    return true
end)

@epfly6 epfly6 requested a review from ConnorForan November 3, 2025 11:57
@EliteMasterEric
Copy link

Was going to test this out but it appears to be under heavy development still (I downloaded d313242 and after figuring out the dungeon generator controller, invoking it caused a 0xc0000005 (access violation) error).

Very excited for it to be stable enough to do more stress testing with.

}

LUA_FUNCTION(place_room) {
DungeonGenerator* generator = GetDungeonGenerator(L);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good for DungeonGenerator itself to contain all its functions such as PlaceRoom (ie, generator->PlaceRoom(config, row, col, seed), and the lua bindings just parse the inputs from lua and redirect to the generator's functions. This will separate the DungeonGenerator logic from the lua bindings, and allow DungeonGenerator code to call its own functions.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump. Refactor this logic as DungeonGenerator::PlaceRoom, and just call that from here.

IE, make lua functions minimal (get params from lua, push return values) and do most other things within the DungeonGenerator

#include "Log.h"
#include "LuaDungeonGenerator.h"

DungeonGenerator* GetDungeonGenerator(lua_State* L) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LUALIB_API DungeonGenerator* GetDungeonGenerator(lua_State* L)

I don't know exactly how important the LUALIB_API bit is, but its typically used for such functions. Might impact properly surfacing lua errors.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump.


std::vector<RoomCoords> forbidden_coords = GetForbiddenNeighbors(base_coords, room_shape, doors);

for (int i = 0; i < this->num_rooms; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest having the generator hold a map that ties gridIdx to roomIdx, in order to quickly find the neighboring rooms, and see if a room already occupies this room's slot, as going through the whole room list is not really efficient.

LUA_FUNCTION(Lua_PlaceDefaultStartingRoom) {
DungeonGenerator* generator = GetDungeonGenerator(L);

int doors = (int)luaL_optinteger(L, 2, 15);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the start room has essentially a predefined config, and GetRandomRoom can only ever pick one room, due to the variant range being set to just the value 2, there is no need to pass the doors parameter.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doors parameter limits the allowed doors for the room. It could be useful to mimick the behaviour of the mega satan door/polaroid door

Copy link
Contributor

@Guantol-Lemat Guantol-Lemat Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to use a system similar to the blocked grid indices, rather than having the user having to deal with doors directly. In the original level gen the doors parameter is meant as a way to specify which doors are necessary, not those that are allowed. Of course, we should still allow control over the doors parameter, when doing very specific things, like how secret rooms and ultra secret room generation has to mark certain doors as allowed, post layout generation.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was basing the doors parameter on the LevelGeneratorEntry.doors parameter, since I'm later setting the LevelGeneratorEntry doors field to that. There it does specify the doors argument refers to the allowed doors.
https://repentogon.com/LevelGeneratorEntry.html

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LevelGeneratorEntry is a wrapper for a LevelGenerator_Room and LevelGenerator_Room only specifies the necessary rooms, not the allowed ones. Most likely the parameter was named like that because the necessary rooms end up being used as the allowed rooms when placing the room.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump. IDK if I have input here but need to decide how we want to handle non-standard door layouts for the starting room.

Maybe DM guantol.

this->shape = room->Shape;
}

RoomConfig_Room* DungeonGeneratorRoom::GetRoomConfig(uint32_t seed, int required_doors) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I presume this function was originally meant to do something else due to the unused parameters and unnecessary check for nullptr, what was this supposed to do originally?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump.

Remove unused parameter, and simply return this->room;

this->shape = room->Shape;
}

RoomConfig_Room* DungeonGeneratorRoom::GetRoomConfig(uint32_t seed, int required_doors) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump.

Remove unused parameter, and simply return this->room;

#include "Log.h"
#include "LuaDungeonGenerator.h"

DungeonGenerator* GetDungeonGenerator(lua_State* L) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump.

}

LUA_FUNCTION(place_room) {
DungeonGenerator* generator = GetDungeonGenerator(L);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump. Refactor this logic as DungeonGenerator::PlaceRoom, and just call that from here.

IE, make lua functions minimal (get params from lua, push return values) and do most other things within the DungeonGenerator


return 1;
} else {
return 0;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer this

  ...
  luaL_setmetatable(L, lua::metatables::DungeonGeneratorRoomMT);
} else {
  lua_pushnil(L);
}
return 1;

for callback in GetCallbackIterator(callbackID, param) do
local ret = RunCallbackInternal(callbackID, callback, dungeonGenerator, rng, dungeonType)

if type(ret) == "boolean" and ret then
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is very little room for any semblance of mod compatibility for this callback. I believe we've discussed this a bit before.

MC_PRE_GENERATE_DUNGEON is simply not suitable for modifying layouts, only creating them. The use-case being served here is completely custom layouts, and that is not something that mods can collaborate on without explicit compatibility.

With that in mind, I wonder if instead of having a return value at all, we could just do this:

RunCallbackInternal(callbackID, callback, dungeonGenerator, rng, dungeonType)
if dungeonGenerator:Validate() then
  return true
else
  dungeonGenerator:Reset()
end

If there was a version of this that was intended to support modifications, it would either be MC_POST_GENERATE_DUNGEON, or a more fleshed out MC_POST_LEVEL_LAYOUT_GENERATED. But that is a weird case. Tacking additional rooms onto the floor is easy enough to do now post-levelgen, so this would come down to practical use-cases for modifying layouts that cannot simply be done later. Stuff to think about/discuss separately, perhaps.

}

g_Game->_lastBossRoomListIdx = this->final_boss_index;

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DungeonGenerator should support off-grid rooms.

Most likely by adding DungeonGeneratorRoom offGridRooms[21]; (just flip the index)

It should maybe be possible for lua to place RoomConfig_Rooms into these slots (but only by placing RoomConfig_Rooms).

Now, if they were not populated by the mod, we must initialize the following off-grid rooms for (mostly) ALL dungeons:

  • Error room (-2)
  • Crawlspace dungeon (-4)
  • Black market (-6)
  • Members card secret shop (-13) (except home & ascent)
  • Stairway to heaven angel shop (-18) (except home)
  • The Rep+ lil portal room (-20)
  • The Beast's room in Home?? (-10)

For generate_dungeon specifically we need these as well, regardless of floor with some exceptions (yes, the game always populates boss rush / mega satan / etc):

  • Boss Rush (-5)
  • Mega Satan (-7)
  • Blue Womb trapdoor (-8) (for all stages except blue womb)
  • Void trapdoor (-9) (for blue womb only)
  • The "secret exit" room SPECIFICALLY for returning to the main path from Repentance alt path floors (-10)

These will (mostly) boil down to GetRandomRoom calls, but please refer to the 1.7.9b logic (easier to read, tho doesnt help for lil portal) to make sure we do this correctly. Ask for help in the discord thread if needed.

{
bool skip = ProcessGenerateDungeonCallback(this, *rng, DEFAULT);
if (skip) {
return;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to call generate_mirror_world here, for the appropriate stages. In theory it should just do what it normally does and flips the main layout... Guess we'll find out if it works.

{
bool skip = ProcessGenerateDungeonCallback(this, *rng, DEFAULT);
if (skip) {
return;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need to confirm if there's anything else we need to do here since we're skipping the vanilla code, like anything the game changes or sets that isnt directly tied to the LevelGenerator portion.

I'll need to defer to Guantol for that, though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants