blog.oat.zone
λ
cd /that-time-i-worked-on-an-isaac-ddlc-mod/

That time I worked on an Isaac DDLC mod

· by jill

To celebrate said Binding of Isaac mod releasing, I decided it'd be fun to go over the history of how I started working on the mod, how development of the mod went, and insights into how the Isaac modding API sucks balls.


So, this all started with the original Isaac Doki Doki Literature Club mod. It was a project made by a few people, led by Crashpunk, back in March of 2018. It wasn't a very big mod, but people enjoyed it since, well, DDLC was really popular back then. It still is, especially with DDLC+ releasing, but its fanbase has been much quieter since.

The Isaac character select, but with DDLC characters in the wheel.
One of the preview images for the mod.

This mod worked well, until Repentance rolled around (which, in short, is a massive Isaac DLC that refactored quite a lot of Isaac). The mod kind of broke, and since the mod had been pretty old, Cyberpunk decided to look for a new programmer to help out with the project.

"Hey all. I'm the creator of the DDLC Isaac mod. I'm looking for coders to help fix up my mod for Repentance..."
The initial message, sent in the Isaac modding Discord.

So, I decided to approach them on the offer. I decided I'd first work on fixing stuff, and oh boy, the existing code.. wasn't ideal, let's just say.

Isaac code mods work by having a single main.lua file in the root of the mod. It gets loaded automatically, and you're recommended to require() other parts of your codebase to prevent clutter. Recommended. Guess what this mod didn't do.

Yeah, everything was put in main.lua.

After taking about a day or so to split the mod into multiple files, I decided to get working on tainted characters. First up on the list was Monika, as I had an interesting idea for her.

This.. didn't work very well.

The main issue was that this is basically just Tainted Judas. It wasn't a very creative idea, and the way I tried to implement it was inferior to how TJudas did it. I couldn't bother improving on an idea that was already implemented in-game, but better, so I decided to scrap the idea. (It's still included in the files, just as an unused file!)

So afterwards I realized I should be putting in more thought in the character designs. Next up, I decided to come up with a thing for Sayori. I was brainstorming one night before falling asleep, and came up with a rough idea - you have a stat boost, but it fades over time, and you need to kill enemies to get it back. It's an interesting idea - you both are buffed 1.5x, and also sometimes are nerfed, depending on how you play the game.

The main issue was how I was going to detect what count as a kill and how much it gives you charge. I didn't have a very good idea in my head, but I ended up going with this:

local div = math.floor((DDLC.SaveData.storedwisps or 0) / StatBottle.bottleMaxPerCharge) * 0.8 + 1
local charge = ent.MaxHitPoints / div

In short, one "charge" was about 69 points (nice), and each time you got a charge, the total amount of points you get per enemy is decreased. That's about all there is to it, but it worked pretty well.

Afterwards, I polished up the Sayori rendering, and afterwards moved on to Natsuki with the final result looking something like this:

For Natsuki, my first thought was basically "Tainted Lost, but the opposite way around". Tainted Lost gets higher quality items, but is also brutally difficult. My intention for Natsuki was to be the other way - get lower quality items, but be able to have some other sort of buff on top of that.

The idea I came up with was one that worked very well with the lower quality items part - an active item you can use to upgrade any items you get to a higher quality after a couple of rooms. Additionally, I already established Tainted Natsuki was going to only be able to get soul hearts, so I thought of an additional use for the item would be to optionally convert hearts and items to soul hearts.

Then I moved on to Monika. I kept Monika for last, because I knew such an important character in DDLC deserves to have a really interesting character, and she was the most fun, since for her I decided to do something.. interesting.

Have you ever played Super Ledgehop?

It's a good game. Please play it. It's also very cheap. To spoil it's gimmick, the mechanic it has goes like this: you're able to "capture" bullets that are shot at you, and then use their types against enemies you face. That's pretty much what I did for Monika, and it worked really well, actually! I even left a little shoutout:

-- shoutouts to super ledgehop!!!!!!
At the very top of src/items/fork.lua

However, I faced a problem. While Super Ledgehop was designed with its gimmick in mind, Isaac wasn't. While on paper I thought, "well, enough enemies shoot unique tear types, it'll be fine", when implementing the bullet types I realised there's a lot less of them than I thought.

So, as a last resort measure, I made enemies that don't have a bullet type assigned to them just.. choose a random one. It's a bit overpowered, but that's just the way I like it.

Afterwards I moved on to Tainted Yuri. I didn't have much ideas for her, as my original idea was too dumb and got scrapped, and the idea I was planning was too ambitious, so I just took what Crashpunk suggested - you get a pocket active item that exchanges your weapon for Mom's Knife for one floor, at the cost of damage that increases with each floor.


So now that we've gone over the short history of the mod, let's move on to some more in-detail issues I encountered, just as an overview of why Isaac's modding API sucks.

Earlier on, I decided to implement a save data system, as I knew this would be better to do really early. The basics of it are this: if a player exits and continues mid-game, the mod should be able to remember their stats, details, inventory, things like that. The base game does this decently with its own secret system, but mods have to use savedata for it.

The save1.dat file, highlighted
I hate you.

There's no (good) savedata API.

Your only option is to use the bundled in JSON library (which SUCKS), or bundle in your own. You only get a single file to work with, and a single save per save slot, because why not make the pain even worse.

After a bunch of fiddling, I came up with a system like this:

DDLC.SaveData = {
  -- transformations
  -- items
  -- etc
}
DDLC.PersistentData = {
  -- currently unused!
  -- also not quite functional - please dont rely on this just yet, it WILL break, and i WILL cry if you do
}

Basically, you can put whatever you want in DDLC.SaveData, and a library I prepared just for the mod would handle everything for you. There's also PersistentData for things that don't get cleared between runs (because I knew this would be needed in a DDLC mod) but that never got used as there weren't many good opportunities for weird 4th wall breaks.

But this system works well, and is still in-use in the release build.


Let's talk a bit about computer graphics. I like them quite a bit. Isaac's API doesn't.

In short, to render anything, you need an .anm2 file - it's an animation format that's only ever been used in Isaac, and you can tell because if you look it up you'll only get results related to Isaac modding, and complaints about the format. I want to note the format wasn't made by the devs, it already existed..

So if you wanted to render a single pixel on the screen, you'd want to create a pixel.anm2 file, load that, and render that. That's exactly what I did.

Two files, one named a single fucking pixel.anm2, and another named a single fucking pixel.png
You thought I was joking?

There's a lot of things you can do once you can render a single pixel onscreen. Since you can stretch sprites, rotate them, and render them all procedurally, you can now do wild-looking effects with them, like such:

It's fun how much you can do with a single pixel.

One thing you'll notice though in that video, is that the glitchy effects go over the UI elements. This isn't easy to achieve; Isaac gives you no access to rendeirng anything on top of the UI without using shaders. And shaders in the Isaac API are, extremely jank. For example, you're given no errors whatsoever if one of your shaders starts erroring, just a black screen. So you may wonder how I'm able to render things on top of the UI.

In short, Isaac first renders the game, then it gives you access to render stuff on top manually with MC_POST_RENDER, then it applies the UI elements, and then it runs shaders and asks mods for shaders with MC_GET_SHADER_PARAMS, running mods' shaders.

Except.. wait, do you spot a vulnerability here?

A graph, showing the game's render process. It first renders the game, then runs MC_POST_RENDER, then renders the game UI, then runs game shaders, and the runs mod shaders. The arrow pointing from game shaders to mod shaders is highlighted in red, and labelled "MC_GET_SHADER_PARAMS".
>:)

MC_GET_SHADER_PARAMS and MC_POST_RENDER are both entirely Lua callbacks, triggered at different points in time during the rendering process. And you can render anything to the screen at any point, regardless if it's in RENDER callback or not..

That's right! You can render stuff on top of the game UI by shoving the render code in the callback meant for querying the mods for parameters to pass into shaders. And, surprisingly, it works!

The only drawback of this method is that you need to have your mod have at least one shader for the callback to ever fire, and that's why the mod has a dummy shaders.xml with a shader that does seemingly nothing.


Let's move on to a funny little tidbit about how tainted character UIs are handled. You'll notice most characters have a little UI for their active item in the bottom left corner:

The ammo select for the Fork item.

This UI is handled entirely manually. You have to detect when the player has a pocket active item, and then render it all out. This, isn't ideal, but it's good enough, like most things in the API.

However, what makes this much worse is that you have to account for HUD offsets yourself aswell. You see, Isaac has this easily forgettable config option for HUD offset - it determines how far from the borders of the screen UI elements are. This is never passed anywhere in the modding API.

I ended up having to rely on Mod Config Menu, a third-party general-purpose mod that adds the support for other mods to add configs for their mods in a user-friendly UI. I specifically went for this one as it has a built-in HUD offset option, and lots use it.

But that's not the end of it. Isaac uses this.. esoteric algorithm for detecting UI elements' positions based on the HUD offset. It's not just "pixels from the bottom right", or whatever, it's a really weird offsetting formula. Here's a reverse-engineered reimplementation of it, made by Sectimus#0261 from the Isaac modding Discord:

function DDLC.HUDOffset(x, y, anchor)
  local notches = 0
  if ModConfigMenu then
    notches = ModConfigMenu.Config.General.HudOffset
  end
  local xoffset = (notches*2)
  local yoffset = ((1/8)*(10*notches+(-1)^notches+7))
  if anchor == 'topleft' then
    xoffset = x+xoffset
    yoffset = y+yoffset
  elseif anchor == 'topright' then
    xoffset = x-xoffset
    yoffset = y+yoffset
  elseif anchor == 'bottomleft' then
    xoffset = x+xoffset
    yoffset = y-yoffset
  elseif anchor == 'bottomright' then
    xoffset = x-xoffset * 0.8
    yoffset = y-notches * 0.6
  else
    error('invalid anchor provided. Must be one of: \'topleft\', \'topright\', \'bottomleft\', \'bottomright\'', 2)
  end
  return xoffset, yoffset
end

Notice how the anchor is something you have to specify, and something that changes the offset value.

It's, not very fun, but it works.


I hope this gives some insight as to how Isaac modding is like, and how the development of the DDLC mod went along. I really enjoyed working on the mod, despite Isaac being the janky bitch that it is, and I hope you enjoy the mod we put out.

And as for today's Jill's coding tip: never make Isaac mods. Thank you for reading, and come back for more next time for more coding tips!


published