5

Making Spelunky 2 on NES

 2 years ago
source link: https://www.patreon.com/posts/56724847
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Making Spelunky 2 on NES
Your Privacy
Patreon determines the use of personal data collected on our media properties and across the internet. We may collect data that you submit to us directly or data that we collect automatically including from cookies (such as device information or IP address). Please read our Privacy Policy to learn about our privacy practices or click "Your Preferences" to exercise control over your data.
Skip navigation
We use cookies to improve your experience using this site. More information
Making Spelunky 2 on NES

The article discusses how a demo was made for a video. If you haven't seen the video, check it out here: https://www.youtube.com/watch?v=afG8Hu7FBQk

In the very beginning of August, I realized Spelunky 2's anniversary was coming up. For those unfamiliar with Spelunky 2, it is a platformer like Super Mario Bros. with an Indiana Jones theme. The levels are randomly generated, so world 1-1 is always different. It contains many items and treasures to aid your character's movement, but where these items are found (or even if they can be found) is generally up to chance.

You can watch a trailer here: https://www.youtube.com/watch?v=2W0ke-t0nzg

You can find it on Steam here: https://store.steampowered.com/app/418530/Spelunky_2/

I had given thought to remaking it on NES before then, but the anniversary seemed like a good time to do it. Sometime on August 17th, less than one month before the September 15th anniversary, I started to more seriously think about how feasible it was. 

I grabbed Modlunky 2 which enables extraction and viewing of assets from the game:

1.png?token-time=1634169600&token-hash=_lVdjjZupn9cBmzGDx5csgsFV38YBZXmPHR3HqCExD0%3D

The size of each tile in Spelunky 2's assets is 128x128. On NES, I wanted this to be 16x16. NES hardware tiles are 8x8, but it wants four colors per 16x16 area, so most games use that size for their smallest structures.

I started to shrink and pixel over assets in Aseprite to see if they would still be readable at this size:

1.png?token-time=1634169600&token-hash=tRJ--t1Rw__jwe_5fozjVpftFa9u6EGRVGJS724rqO0%3D

Iteration is the key to success! Here is a comparison with the original character from the game, Ana Spelunky:

1.png?token-time=1634169600&token-hash=kWf8-I6NV4169X_Wrv5yy7aQ-darw1kfwWCFUSiUc5g%3D

With this single sprite made, I started to investigate level generation. Spelunky 2's levels are made of "rooms" which are made of "tiles" and "chunks". The typical room size is 10x8 tiles, but there are exceptions. A chunk is made of tiles, and is basically a mini room that a regular room can contain. A typical chunk is 5x3 tiles, but there are exceptions. A typical Spelunky room might look like: 

1.png?token-time=1634169600&token-hash=Jdh6NI2kA9VTl-DUGhSyvGTRCoZEeyDkBWWJR0uxRXM%3D

Spelunky 2's level data as extracted by Modlunky 2 assigns one character to each type of tile, like so:

\?empty 0

\?floor 1

\?minewood_floor =

\?ladder L

\?ladder_plat P // Ladder with platform across it

\?coffin g // Character coffin (NPC/Player)

(As far as I know, Patreon does not have code tags. Some things in this post may be edited into images for easier reading in the future.)

Using 0 would place an empty tile. Using 1 would place a basic solid tile. The data for the above room looks like this: 

0========0

0=00000L=0

0=L0g00L=0

0=P====P=0

00L0000L00

00L0000L00

00L0000L00

1111111111

Here is the room with these characters overlaid on top of it:

1.png?token-time=1634169600&token-hash=bNcixl3YJ5ZEsQZlZucewqMop4ze6fMuzZIcDBEa44I%3D

Some parts of how this appears are not related to the basic room data. Some of the tiles have gold in them. The empty portions can have random designs, and the tiles can even visually connect to tiles outside the room as is happening in the wood portion near the top left. Some of how these things work will be described a little later.

The first real step was getting all of this room and tile data into a more friendly format for the NES. An NES program is typically "assembled" by a program called an "assembler". An assembler needs all names (human readable pieces of text like "empty" or "floor") to resolve to a single number like 0 or 1. 

In the assembler used for this project (ASM6), this can be done by typing <name> = <number> on a new line. A "comment" can be added by adding a single semicolon (;). Anything following the semicolon on the same line will be ignored by the assembler while building the program the NES will run. Comments exist to help the code be understood by programmers in the future. I wrote a separate program that read the Modlunky 2 exported level data, and formatted it in a way the assembler, ASM6, would understand. A first pass at assigning each tile type to a number looked a little like this:

empty = 0;0

floor = 1;1

minewood_floor = 2;=

ladder = 3;L

ladder_plat = 4;P

coffin = 5;g

Each comment is the original character for each tile type. "minewood_floor" could not stay '=' because '=' is not a number. Here it is assigned to 2. When each tile type has a number, the room data can look like: 

1.png?token-time=1634169600&token-hash=xqWjdSuiyTbOdwbHWBwCJkyTWHwIioG-dNGyT2EfQgk%3D

.db tells the assembler the line will be used to insert numbers directly into the program as bytes. "empty" will place a 0. "minewood_floor" will place a 2. A comma tells the assembler a new number has started. This format is not neatly aligned, and is less readable than the original room data. But an assembler is very particular! Because "empty" really does just mean 0 (as it was assigned to 0 earlier), and "floor" really does just mean 1, the data could also look like this (the final program would not change at all):

.db 0,2,2,2,2,2,2,2,2,0

.db 0,2,0,0,0,0,0,3,2,0

.db 0,2,3,0,5,0,0,3,2,0

.db 0,2,4,2,2,2,2,4,2,0

.db 0,0,3,0,0,0,0,3,0,0

.db 0,0,3,0,0,0,0,3,0,0

.db 0,0,3,0,0,0,0,3,0,0

.db 1,1,1,1,1,1,1,1,1,1

This might seem a little nicer, but names are useful for many reasons. One is helping to find errors during the conversion.

Spelunky 2's rooms are divided into types. This helps with how the game randomly generates levels. Say the game needed a starting door (or "entrance"). It could pick a random room from the "entrance" group and be sure none of those would create an unfair experience. Here is the example room we've been using with a bit more data:

// ------------------------------

//  TILE CODES

// ------------------------------

\?coffin g // Character coffin (NPC/Player)

////////////////////////////////////////////////////////////////////////////////

\.coffin_player // Coffin room holding a dead player (both sides must be open)

////////////////////////////////////////////////////////////////////////////////

0========0

0=00000L=0

0=L0g00L=0

0=P====P=0

00L0000L00

00L0000L00

00L0000L00

1111111111

Astute readers may have figured out that in Spelunky 2's level data // starts a comment, just like the semicolon for the assembler. \? means what follows is a name for a tile type, and then the character used to represent that type. \. means what follows is a name for a room type. All rooms that follow are added to the group for that type, until another \. is encountered. So far, so good, but look at this:

// ------------------------------

//  TILE CODES

// ------------------------------

\?idol I // Idol statue

////////////////////////////////////////////////////////////////////////////////

\.idol // Idol room

////////////////////////////////////////////////////////////////////////////////

The conversion program would encounter the first "idol" as a tile type and maybe assign it 6. It would then encounter the second "idol" as a room type and maybe assign it 10.

idol = 6;I

idol = 10;Idol room

Each name needs to resolve to a single number! The assembler doesn't know what to do for ".db idol" now! This is an example of a "naming conflict" in programming. To fix it, I made the conversion program append "_tile" to each tile type.

idol_tile = 6;I

idol = 10;Idol room

No more conflict! There were other issues related to names from Spelunky 2's level data. This code:

\?floor%50 2 // 50% chance of floor, 50% chance of empty

has a '%' in the name, which is not allowed by the assembler. '%' can also have an interesting effect in the language the converter program was written in (C++), but I will spare you the specific details. The short of it is that this had to change too :

floorpercent50_tile = 7;floor%50: 2

(For the programmers reading: There is also circular dependency in Spelunky 2's level data. There is a generic set of room data that uses tile codes only defined outside of it, and then world specific room data that uses tile codes only defined in the generic room data.)

This simple conversion of room data took longer than anticipated for these reasons.

Spelunky 2 also allows rooms (and chunks) to be mirrored horizontally:

1.png?token-time=1634169600&token-hash=ezZOloYwZsD1iOrXQyx-NepeW_X8ITCqINNpUSb8e88%3D

As before, some cosmetic things are a little different. The left one has a dinosaur skeleton in the ground, but it's the same basic room data:

////////////////////////////////////////////////////////////////////////////////

\.entrance

////////////////////////////////////////////////////////////////////////////////

\!flip

1======111

0=00000000

0=00900L00

0======P00

0000000L00

0000000L00

0000000L00

1111111111

When \!flip is encountered, it means the next room can be used as typed or it can be used mirrored on the X axis.

Flipping can be implemented by having the game read the data "backwards" sometimes, or it can be implemented by having the conversion program create a duplicated, flipped room. For the second method, the game itself is not aware the data is flipped, it just chooses between two different rooms. For Spelunky 2 on NES, I opted for the second method. This uses twice as much data, but it is easier to write code that flips rooms in C++.

512 kilobytes was actually fairly large for an NES game from the era. Larger games were made, yes, but this was still a premium size. The original Spelunky 2 is more than 512 megabytes, which is 1024 times the size of these large NES games. I wanted the level data for the first world to be about 8 kilobytes (8192 bytes). For comparison, the level data file for the first world in the original game is 21 kilobytes, a little less than three times that. This is even missing some information that is needed in a file that contains data that can be shared between all worlds. That shared file is 13 kilobytes. However, both contain a lot of information a computer doesn't need, like comments.

Exporting everything as already described with duplicated data for rooms that could be flipped ended up being around 15 kilobytes, a little less than double my goal. I forget if this was before or after I started excluding data for rooms with behavior I did not intend to implement, but regardless it was still too large. Making it so the game itself flipped the data rather than the converter duplicating the data might have got me under my goal alone, since it would end up roughly halving the total size. (Not all rooms and chunks can be flipped, but most can.) Still, I opted for trying something else.

Here is some room data from the original game:

1111111111

0021111200

0001111000

0000ff0000

0000ff0000

0001111000

0021111200

1111111111

And here is what it ended up as:

1.png?token-time=1634169600&token-hash=6Wqbxg8Z0Q8uDFKB4Pm3Gtdi3Ku-0LRMfkdfS3O50nc%3D

% in the assembler makes it interpret what follows as a binary number. This is why the name "floor%50" had to be filtered earlier. Here %10000000 | <tile type> means that the entire row will be the same tile type. (This is designed by me, not an assembler specific command.) This saves 18 bytes for this room. A row contains 10 tiles, but only one number is used to specify a repeat is needed. Two rows entirely repeat, and 9 bytes are saved for each.

Here are a few more examples with larger savings: 

1.png?token-time=1634169600&token-hash=e6rWRB6ejuTzjX4KqvknUHPGMvA4seQ9-UuSz5e5ZCY%3D

After doing this, I ended up at around 11 kilobytes. Still over my goal. I ended up making the level conversion program exclude structures I didn't plan to use, and still ended up a handful of bytes over the goal. I will probably end up making the game flip the data rather than storing it duplicated and do some other things to keep the data smaller, but at this point in the project there was a lot more to do before the anniversary.

I started to write the code to actually read the data:

1.gif?token-time=1634169600&token-hash=_f8-Govwm16fZ33pmlRG-uuiSHQrl5EYUAPE3ar0LpY%3D

It's copying a random room or chunk into NES memory. This is a little hard to make sense of because the columns aren't aligned. Rooms are typically 10 tiles long, and rows here are 16 bytes. 5 rows of 16 bytes is 80 bytes, and a room that is 10x8 is also 80 bytes. Newly copied bytes turn red. When the area is smaller than 5 rows, it means a chunk was decompressed. When the area is larger, it means a room larger than the typical size was decompressed.

The code for generating random numbers to choose which room or chunk to read was written by another NES developer on Patreon. You can read more about that subject in this article here: https://www.patreon.com/posts/random-number-30322874

This was a first attempt at decompressing with "chunks":

1.gif?token-time=1634169600&token-hash=gjlDx4KTJ0hpUC7JFCALUGgaAKDUWjghzGJJygkc9xU%3D

Chunks are much like rooms, only smaller. If a room contains a chunk, a smaller set of tile data is copied into the room from a random chunk of the specified type. The above shows the same room getting loaded, but because it contains chunks, parts of it end up different based on those random rolls.

The final piece of the puzzle was making 50% tiles work. Some tiles have a 50% chance of being something else. A commonly used one is a 50% chance of being ground, and a 50% chance of being air. This earlier image from Modlunky 2 shows some of these tiles:

1.png?token-time=1634169600&token-hash=34wzxbgq8lIst4k1pc5tGs1F1qABs7QDrWWxhMxfwcQ%3D

In the animation above, sometimes the number 20 appeared. These are 50% tiles. Here they finally resolve to either ground or air:

1.gif?token-time=1634169600&token-hash=LtWpcs5bdk_fGmyNk4_n8WpjnvdFRd9lbXw0q1Q8H7w%3D

This is all the same room. Spelunky 2 is random rooms, that can contain random chunks, that can contain tiles that randomly roll between two choices. Phew!  With the data in memory verified, it was time to get it rendered.

The NES can be thought of as having two independent layers. One is for the "background" where the visuals for the level/world typically appear. The other is for "sprites" where the characters typically appear. 

The background is a fairly rigid grid, but can have pixels that cover the entire screen. Sprites can be placed freely, but there aren't enough of them to cover the entire screen.

Updating the background during gameplay is one of the most challenging pieces of NES development. What many, many modern games do is redraw everything every frame. NES hardware technically redraws everything every frame too, but making it draw something different than it did last frame requires updating the things in memory it is drawing from. There is a only a small amount of time to make these changes if the player is looking at anything besides a completely solid color. 

Suppose you did want to change the entire background the player was looking at in one frame. A full screen of changes is 1024 bytes.

The fastest possible kind of update that can be done is writing just one number to the whole background tilemap. If just this was done in the fastest way, about 556 bytes could be updated. A bit more than half. But that's not that useful for a game. You'd usually want to update sprites too, which costs a little time. And you'd want different numbers put into the tilemap, or you just end up with a screen filled with one repeating tile. Updating sprites and changing the tilemap in a fast way that remains versatile for general use could get you about 213 bytes updated in a single frame. Barely a fifth. You could make changes the player couldn't see over the course of five frames, and then reveal them all at once in one frame. But you couldn't show an entirely new picture every frame by updating the tilemap.

Even this magic 213 number assumes the updates can happen in the fastest possible way. Generally they can't happen that fast. The challenges involved with doing background updates can even make clearing lines difficult if one were to make a Tetris clone. A typical Tetris playfield is 10x20. That's 200 bytes. Now imagine having two players playing at once, with both clearing their playfields!

How did Nintendo's Tetris update its playfield? Well, not all at once:

1.gif?token-time=1634169600&token-hash=WF4WdfEQYIpXtRG7o_-DE2uiJ33b-kBfCJ20wl4u0eU%3D

Some common reasons to update the background during gameplay are updating the HUD information, removing collectibles like coins, and scrolling. Here is Super Mario Bros. doing all three of those: 

1.gif?token-time=1634169600&token-hash=-7CFEkbzjWQ4mABk_0y8Z0sKRa-CPHJ2IdJHFziB820%3D

Mario touching a coin causes blank tile numbers to be written where the coin is. This means next frame black is drawn instead of the coin.

When Mario collects a coin, his score goes up as does the count of coins he currently has. These numbers are also part of the background.

Finally, by running right, more of the level is revealed. Drawing the new parts of the level that appear is also a background tilemap update. 

It might seem like scrolling on the NES really is giving the player an entirely new background to look at, but it's more like moving a little frame over a larger picture. The entire background tilemap is larger than the screen. Here is Bomberman walking in a long level:

1.gif?token-time=1634169600&token-hash=PlEht5FYec6tA-1wbKaV_e-jo9RKDEEVFL2pv8z7itw%3D

On the left is what the player sees. On the right is background tilemap in memory. The portion of the tilemap that is on screen is highlighted. Except for the timer counting down, no changes are actually happening in the background! Scrolling around what's already in memory is very, very easy. But updating new tiles as they appear can be a small challenge. Here's Super Mario Bros.:

1.gif?token-time=1634169600&token-hash=RVjXHU2JsU1jjsltFrsJ-cWgWfebMgGOxf7NAdw7R6w%3D

It's changing the tilemap before the "frame" of the screen reaches it on the X axis. Here's Ice Climber:

1.gif?token-time=1634169600&token-hash=qdhMWR6yg9Qhj1XQP-6vzkLhq_1KgiheKZeuvSbhgLs%3D

It's changing the tilemap before the "frame" of the screen reaches it on the Y axis.

And for something slightly more interesting, here's Metroid:

1.gif?token-time=1634169600&token-hash=GNMWEzMps7shiKSsHDD76W8mOeEAxLG1mFnwskiy6T0%3D

It's changing the tilemap before the "frame" of the screen reaches it on the X axis sometimes. And on the Y axis sometimes. Each time you go through a door, it can switch which axis the new room will scroll on. It was pretty rare that games would let you freely scroll on both axes at once. The reason for this is hidden in that piece of Metroid gameplay. NES has access to four screens worth of tilemap, in theory. But generally, two of them had to match the other two. You can see Metroid switch the matching sets after the door transition:

1.gif?token-time=1634169600&token-hash=C48Nu3Ar-eOPMCqYTAnz__sLl-4mPhkONJYOhE9jO48%3D

What this means in practice is that you typically have an offscreen buffer to make updates, but only on one axis. Games that scrolled freely on both axes tended to have color issues by the edges:

1.gif?token-time=1634169600&token-hash=ozKyvm4_FY7k_AHlnXbUfHyzQqakHknqAha3zbTGi0o%3D

(Vertical scrolling is not shown here, but the game does also scroll vertically.) 

Look near the right edge of the screen and you'll see the white block is occasionally green and blue. The green block is occasionally white. The ? block is green rather than brown. Super Mario Bros. 3 has an offscreen buffer on the Y axis, but not the X axis, so updating the colors on right edge of the screen will also affect the left edge, and vice versa. Not having an offscreen buffer on one axis makes the whole thing very challenging.

You may have forgotten at this point that this post is about Spelunky 2! Well, Spelunky 2 needs scrolling on both axes, and scrolling on both axes is fairly difficult. I had code to do this kind of scrolling already, but Spelunky 2 generates its levels in RAM. The way the old scrolling code expected the level data to be was not how Spelunky 2 wanted to do it, so this had to be adapted.

There are four basic parts to scrolling in both axes. I will use GIFs from an old project to demonstrate. The sprite is based on Ajna from Indivisible. The tile graphics are original, from an old project.

1. Updating tiles on the X axis:

1.gif?token-time=1634169600&token-hash=InS_C6oGtZoDmOq94Ru7UdcRMW7TBOOKeJ2XTC8lU_A%3D

The colors are totally wrong. Color updates are separate from tile updates. But you'll see that the structures here look cohesive so long as scrolling only happens on the X axis. Nothing happens when scrolling for Y. This is because the "frame" is moved, but the tilemap isn't being changed yet for that case.

2. Updating tiles on the Y axis:

1.gif?token-time=1634169600&token-hash=6DLkrGzLMdc9m1IcU-R6ZcJbENqUHtVobbJqEsECEr4%3D

The colors are still wrong. But you'll see everything else looks fine regardless of axis for the structures.

3. Updating palettes on the X axis:

1.gif?token-time=1634169600&token-hash=6yCuIEMV7TyJZu_x31hXCEihZdxJg26HEytMT_pGSKg%3D

Pay attention to the checkerboard pattern. It's meant to be blue and light blue. When scrolling happens on the X axis, it is the correct colors. When scrolling on the Y axis, occasionally the pieces that are meant to be dark blue end up green, or gray.

4. Updating palettes on the Y axis:

1.gif?token-time=1634169600&token-hash=Hy04L4BBTTyzXy1n10U0qpFS-3AGUMwIO44EkOGX4Q8%3D

Everything is wrong at the very beginning. This is because when the level is loaded, no scrolling happens to draw what should be there at the start. But once scrolling does happen, everything is correct.

If you update what should be there on level load, you get a truly finished implementation of multidirectional scrolling:

1.gif?token-time=1634169600&token-hash=dp2XyGt8igEO1hicGk0sNk7CLOuk3tH4I53YfR2GO3M%3D

You might still catch some color errors by the edges of the screen, but this is due to there being no off screen buffer on the X axis, rather than some tiles or palettes getting "missed" when they should have been updated. (I recorded these GIFs almost six years ago intending to write about this process! It's good to finally use them!)

A frequent cause of errors when writing scrolling like this is tiles in the corners getting missed by both axes and ending up un-updated in the center:

1.png?token-time=1634169600&token-hash=EnNS5RHgwj8qTiqUNiaVzTvA6NS15ptMl5F3apKqAWs%3D

These cases can be very rare, if they exist. Many people who attempt this kind of scrolling think they have it working for a long time, until an error like this pops up under very specific circumstances.

Spelunky 2 went through the above basic steps, but I don't have recordings from each step. Here is tile updating on both axes, but no palette updating:

1.gif?token-time=1634169600&token-hash=F3W0yg6dO8K60-yO_SaLH_zqC2PAPvpTIJg0k-RG814%3D

These tiles were made for the Indivisible project and were used as placeholders here. While I was at this stage, I took a moment to compare my progress to the actual game: 

1.png?token-time=1634169600&token-hash=TAj8baJaLNwqEPGosmjwQQlb9tZqfo1Hp17RSsOjy_k%3D

I hadn't done any work on tile graphics yet, but the similar "shapes" of the room with the ladder can be seen.

Here is scrolling with palette updating: 

1.gif?token-time=1634169600&token-hash=TX5iGmijvhG31qmcCjO2ppn-e4tNousby7n3TB8Hwog%3D

These still aren't Spelunky 2 tiles, but people familiar with the game can probably identify some common rooms! However, the rooms shown here are completely random. Spelunky 2 typically starts you at a door at the top of a level, and the goal is to find the door at the bottom of the level. Spelunky 2 players may see there's not always a path down like there should be. I had the room data from the original game, and I could draw it to the screen, but I could not yet create a path from the start door to the end door like the original game.

I strongly recommend reading this post if you are interested in how Spelunky level generation works in a general sense. It is not specific to Spelunky 2, but it's nice and interactive. Note: To my knowledge, as of this article, Spelunky 2's level generation has not been fully reverse engineered and publicly documented. Some of what I'm doing to generate levels is based on the older games in the series, and a bit of intuition.

Levels in the first world of Spelunky 2 are typically 4x4 rooms. This means there are four possible places to put an entrance door at the top of the level. Rolling a number from 0 to 3 chooses where to place that first door. 0 is against the left wall, 3 is against the right wall. From there, a number is rolled from 0 to 4. If 0 or 1 is rolled, the game places the next room to the left of the current room. If the game rolls a 2, the game places the next room below to the current room. If the game rolls a 3 or 4, the game places the next room to the right of the current room. If the current room is already against left wall, and the number rolled wants to move left, the next room is placed below. It is the same if the current room is already against the right wall, and the number rolled wants to move right. 

After moving down, the process repeats, but once a row has moved either left or right, it cannot move in the opposite direction. It can only continue in the same direction on the X axis or move down. Moving down being slightly less likely than left or right makes the levels a little bit longer, but still allows for lucky, straight down paths. When the very bottom of the level is reached and the number for down is rolled (or a wall is hit on the X axis), the ending door is placed.

Here is what we'll call main path generation:

1.gif?token-time=1634169600&token-hash=pH3dn1lfWleOAMZrwictGsUW4GxdO_xCfdyZN3JiJ8Q%3D

It's just numbers in RAM!

00 is a room that is not part of the main path.

01 is a room that connects to the sides on both the left and the right.

02 is a room that drops into the room below.

03 is a room that is open on the top, but not the bottom. 

The starting rooms actually aren't marked differently here, but they are kept track of!

Once you have an initial path, you can place a shop. Shops have a 2/X chance of of appearing in a given level, where X is the number of levels you have cleared + 1. There are some caveats, like a shop cannot appear on the first level. One would think it was guaranteed on the second level ((1 level cleared + 1)/2 is 100% of the time), but other things that can be generated can steal the room it would occupy. In any case, first the roll is done to decide if a shop should be generated at all. If it is successful, the shop needs to placed in a room that is immediately to the left or right of a room on the main path, in a previously unoccupied room. So in the animation above, any 00 that is to the left or right of any number that isn't 00 can contain a shop. I wrote a simple loop to check which 00s were next to other things and counted them. Then I made it so a number less than this count was rolled. That 00 was made into a shop.

1.gif?token-time=1634169600&token-hash=7Z6LHl0L9z4lpAElj8DzhMwdGOPndPJLbXtaMbJkiSQ%3D

04 represents a shop. This GIF was made with the assumption we're on level 2 (so a near 100% chance of a shop). There is logic for choosing whether to generate a shop in the first place as well.

1.gif?token-time=1634169600&token-hash=SVhoDNWdJAkSiXis3bd4rTXW-Lukx-mJ-Ot_CxJeSsM%3D

The above GIF is rapidly rolling. I believe I was trying to see if it ever failed to generate shops. (The debugger would have stopped the rolling in this case, I wasn't trying to find it by eye!)

Finally, Kali altars. Spelunky 2 occasionally generates an altar one can sacrifice bodies on. Altars don't need to appear along the main path, but can occupy any otherwise unfilled room. An altar is represented by 05:

1.gif?token-time=1634169600&token-hash=hRdlV8W8_VDV2rsP7qJNFF90w_Y8hkaIaJad5q3KI4E%3D

Unlike the shop GIF, altars are not effectively guaranteed to appear. I stopped rolling for a bit whenever I saw one, though.

With this, the basic type of each room was decided randomly. I had said room data is grouped by type. With a path set up, the only thing left was to generate a room from the group that represented each number. 00 would roll a random "side" room. 01 would roll a "path_normal" room. 02 would roll a random "path_drop" room if there was not another 02 above it. Otherwise, it would roll a random "path_drop_notop" room. 03 would roll a random "path_notop" room. There are several types of shops, so 04 gets a touch more complicated. Finally, 05 would roll a random "altar" room in theory. But there's only one possible altar room!

Entrances were rolled from either the "entrance" or "entrance_drop" group based on if they were an 01 or 02. Exits were rolled from either "exit" or "exit_notop" based on if they were an 01, or 03. Rolling random rooms for each piece of the 4x4 grid and scrolling through them already worked, so it wasn't too big a change to roll from the correct groups based on those numbers:

1.gif?token-time=1634169600&token-hash=TPu889av8j6Z1Bat3EIFd9QhLVePyYimB3_pbRSAPk8%3D

It was starting to look like Spelunky 2! But there were some issues. Sometimes, shops would generate facing away from the main path. For example: A shop generated to the left of the main path with its door on the left instead of the right. This is because Spelunky 2's shops are not tagged with \!flip. I made my level data converter sneak it in. As well, it occasionally made impossible levels, due to a silly naming issue...

1.gif?token-time=1634169600&token-hash=NUDW7zFG_lJjvuY84NNZA2HXZe-UPTlvJElgGyRrYzc%3D

1.gif?token-time=1634169600&token-hash=6cScTIRthAtqBIoX0Cboepil6eY8wekFVcRiPN_n3W4%3D

With that fixed, next up was a mostly cosmetic change. Spelunky 2 borders its levels with indestructible tiles. First I made room for them and expanded the scrolling borders: 

1.gif?token-time=1634169600&token-hash=V6dZn_t9S_Ery5mcKND6uLSDY55PEpJc0LjAMOF8Bq4%3D

Then I wrote the actual tile numbers there:

1.gif?token-time=1634169600&token-hash=nE--fllN5g5hZztIAukWBDuE-EzCHyK0P79UUf_pHis%3D

I made it so that when a new level is loaded, it actually set the scroll position to the door:

1.gif?token-time=1634169600&token-hash=JonctDZLzr1V_QIBtwComW0DZUJZIGIo_3wvt2kJSMQ%3D

And I started to add a little palette fade code:

1.gif?token-time=1634169600&token-hash=yfhdUZT0ww9OZ4J5m3eNXdcR6bLMEGO4--q5qb_0OEk%3D

And with that crash fixed:

1.gif?token-time=1634169600&token-hash=Twfrlhest6buzeWfYOMtHzw1jJVhAFyQXkhoFBKSZIo%3D

I ended up spending a lot of time on something that wasn't even seen in the video. Recall the theoretical 213 bytes of sensible updates per frame. In addition to scrolling, Spelunky 2 allows nearly full destruction of its levels with bombs. 

Each bomb might destroy up to 21 tiles:

1.png?token-time=1634169600&token-hash=dj0Yf3bmoqFwngoelggaDs7e8H4XiL7KKyreBAgQLmM%3D

Although on NES, the tile size is 8x8 pixels, each tile would be 16x16 as far as gameplay was concerned. Thus, removing a single gameplay tile requires up to 5 bytes of updates. Four 8x8 tiles for each corner of the 16x16 gameplay tile. Then an update to change the color of the destroyed tile. 21x5=105. A single scrolling column might be about 39 bytes. A single scrolling row might be about 40 bytes. 105+39+40=184 is under the theoretical ideal, but pushing that much data requires the updates to be easy to push. The bomb's pattern doesn't really allow this. 

In addition to a bomb requiring a lot of data to be updated, on screen updates that were not related to scrolling were something I had never done before. I had actually specifically avoided them in previous projects.

If a game doesn't scroll, these on screen updates tend to be pretty easy. They're not competing with time for scrolling updates, and where something is in the "game world" maps pretty directly to where an update needs to be made in video memory. 

The basic unit of memory the NES deals with is a byte. A byte allows for 256 unique values. For a position, let's consider them to be 0 through 255. NES' screen width is 256 pixels. The positions match pretty directly. 

To deal with values larger than 255, you simply use multiple bytes. Adding 1 to the highest value a byte can be (255) makes it 0 again. But you can carry a 1 into another byte. It's not unlike adding 9 to 1 to get 10. 

The value of the byte that gets carried into represents which screen you're on. The value of original byte is your pixel position on that screen. So for a game like Super Mario Bros. that only scrolls on the X axis, the math is still fairly easy.

NES' screen height is 240 (0 to 239). A byte is 0 to 255, so the Y axis gets pretty annoying. Positions from 0 to 239 map directly. 240 is actually position 0 on the screen below the top screen. 255 is position 15 on that same screen. Each additional screen something moves down loses 16 pixels from a screen position. You end up needing some form of division (or modulus), and NES doesn't like division. It doesn't have a native way to do it (for numbers that aren't a power of two), and it typically ends up slow. The 240 pixel screen size ends up easier for scrolling because scrolling can just progressively lose that 16 pixels as it goes. If game objects try to do this, it ends up making interactions between them harder to calculate.

Unlike the X axis, where one byte is just "which screen you're on", and the other byte is where you are on that screen, math has to be done on both bytes (the whole number) to find out which screen you are on, and where you are on that screen for the Y axis.

Finally, a Spelunky 2 bomb can affect tiles both on and off screen (unlike Mario in Super Mario Bros. grabbing a coin which would always be on screen). If you make an update partially on screen and partially offscreen with some obscure mistake, scrolling might cause a visual issue to be carried to the center of the screen.

So a full bomb update might be too much data to push in one frame with scrolling, finding out where to make the updates required is difficult, and updates must not touch anything on screen scrolling hasn't.

I started to write a test mode for this sort of on screen tile update:

1.gif?token-time=1634169600&token-hash=q2b5hL2vAVfuOs5zOXWiaB8LCa7oAm846vNxxIk-S4k%3D

Select switched between moving the cursor and scrolling. Start switched between fast movement and slow movement. The basic idea was that A button would be used to destroy a tile where the cursor was. Scrolling could then be performed to see if any issues appeared. I made it so that pressing A affected the level map in memory:

1.gif?token-time=1634169600&token-hash=jVrrnsVY-UEu1xVuKaqVcl6_mxB9xPTEalexmYcbobo%3D

1.gif?token-time=1634169600&token-hash=grSvCSXP931gG_5cBUZRWsg6Lxou3sCoHKTm8cVnlCY%3D

Scrolling away, then back revealed the changes. This meant for gameplay purposes the updates were made in the correct places. Objects in the game interacting with these tiles would see them as empty immediately. But the player would not see them as empty visually.

1.gif?token-time=1634169600&token-hash=0Xbe6ORVJP2YpYxcV0ysJF466JQtnonwDsoJRl83nA8%3D

Things are starting to look good. This doesn't yet update all four tiles or the color of the destroyed tiles, but the math is correct for an on screen update. But look what happens when updates are made offscreen:

1.gif?token-time=1634169600&token-hash=jW3VbjeSt3VBroKnDKYVw1Y9Gc25AZwY3IXjJUndY_I%3D

Earlier I had said that a color update on the right edge of the screen would also affect the left edge. This is happening for a similar reason. There's no offscreen place to make updates past the right edge of the screen, so the updates just wrap to the left edge. This is why the updates have to be scrolling aware. I added a way for scrolling to store where it last made updates, and tried to use that information to stop "bombs" from updating too far from there:

1.gif?token-time=1634169600&token-hash=4P3xUsBLyhkyCSghB5brh-ZjD2oZvMvzRtWLqoPrln8%3D

Well, pretty close! One tile's worth of updates that was supposed to be offscreen ended up appearing, but fixing that was as easy as making one number a little smaller.

1.gif?token-time=1634169600&token-hash=6RiVNnOPDVSLHt4gYA4ZwqL4244P7t0Tmi4GJhvBeq4%3D

At this point the tool was updating all four tiles and the color. After testing and tweaking how far from the scrolling position's origin updates could be made, I packaged this up to give to some people to test. This image of things to look for was sent with it: 

1.png?token-time=1634169600&token-hash=RgbAbjMR0Lxm__iQhMjGqYMa4ZaXt15NZCARlPq7lp8%3D

No errors were found that were related to the actual things I was worried about. Some were found related to how the test itself was written, but I was happy with that. I would say my confidence level is still low that there aren't any hidden errors...

I began to work on the tile graphics so that the placeholder tiles I was using could be replaced. I don't have a lot of process saved for this, unfortunately. Modlunky 2 allowed me to easily rip the assets from the original game. Then Aseprite allowed me to shrink them, match them to NES colors and edit them:

1.gif?token-time=1634169600&token-hash=tvANDNkkmuNLz6clqO9ZAct9Tn5soINy3QoHm-Xn7ME%3D

The original Spelunky 2 tiles are 128x128, and the ones in the NES version are 16x16. Often they weren't very readable shrunken this much, but leaving them a little bigger and drawing with that as a reference could work out. Here were two of my earlier attempts at these bone blocks:

1.png?token-time=1634169600&token-hash=LqlQjFBUBLLGhAhSJGW5J9VZGp2rIpndBhY8Y6V-cjc%3D

At this point I was only using three colors (black, dark gray, and white) and was allowed four. So I pushed the white portions to a lighter shade of gray and used white for highlights:

1.png?token-time=1634169600&token-hash=TtunYCRgp-viYIjREOpged5-neuZR0e_DbZbyTT2rcY%3D

I made the spikes with the same four colors and a similar method. Aseprite recently got beta support for tile editing, and this was used liberally throughout the tile making process. It looks like this:

1.gif?token-time=1634169600&token-hash=dJ2qPnv_lyoSIBnFLuGUA3esZ15QFnmtUUimIVqRHlA%3D

You can make a change in one tile and see how it will look across various connections in real time:

1.gif?token-time=1634169600&token-hash=V-lPNHleGOk71qsBnL7deHZbYo3dIXsSturwa3vP_Js%3D

This made making the tiles for the project relatively easy. A basic layout was used for the tiles which you can see on the left side of the animation above. Here is a more abstract version: 

1.png?token-time=1634169600&token-hash=pDKGAktyLesMc-a_I_oe-OAEq42LpTZqur3QspxJbeI%3D

In the top left is a tile to be used when no other tiles of the same type are surrounding it. In the bottom right is a tile to be used when there is a tile of the same type above it and to the left of it. It represents a bottom right corner. The top right is a tile to be used when there is only another tile to the left. To the left of that is a tile to be used where there is another tile to the left and right. These basic 16 tiles allow visuals to be created automatically based on surrounding tiles, although the layout that makes this work is a little less human readable:

1.png?token-time=1634169600&token-hash=g94iCvVmmK9mLZRbdEbXQOGLIcOwWxpwuzc5T18FZc8%3D

These are the same tiles, just in a different order. Suppose you have one tile, and you want to decide how to display it. You can look at the tile next to it in each of four directions. If a tile of the same type is there, the number listed is added:

1.png?token-time=1634169600&token-hash=Z4SV5PNbSdQZ-cIbPzgt7Pi8VWYtehHjmZMG3PUl_v8%3D

So let's say there are no tiles around the tile. Then nothing is added. That's 0. We get 0 from the abstract tileset. Let's say there is a tile above and to the right. We add 1 for the tile above and we add 2 for the tile to the right for a total of 3. We use tile 3 from the abstract tileset:

1.png?token-time=1634169600&token-hash=GWFDejNO9c1HlkDMkqobAq9CeHNtuZKYAN0piKvH_Us%3D

This happens to be the bottom left corner! Let's say the tile is surrounded on all four sides. We add 1 for the tile above, 2 for the tile to the right, 4 for the tile below, 8 for the tile to the left for a total of 15. We use tile 15 from the abstract tileset, which happens to be the "center" tile:

1.png?token-time=1634169600&token-hash=Vu0t_nFN0kRpJCy2gS60xVTzIcUO0xs2vWR-4Af-kR4%3D

Each addition simply needs to be a different power of two, and if the tileset is ordered to match, this concept can work! Here is a real time example:

1.gif?token-time=1634169600&token-hash=EFqLgQV1w5z8DW92TroKS5f70YsgLNjzpIcX-H_CbPc%3D

I wrote the above program to help with the graphics. I did the art in Aseprite, and occasionally loaded the tiles in this program to see how it would look with some of the more obscure combinations. This program was also responsible for taking in the "human readable" order and converting it to the mathematically correct order.

I also put together a very small tile properties manager:

1.gif?token-time=1634169600&token-hash=rWbYSaiTtHsI4w-nTOpsO-0YtZ7R4UYF-ncXgi-J50s%3D

That shows a few tiles that ended up getting made but weren't seen in the video. At this point it was two days before the anniversary and I had not even started on player movement. I also didn't have any animations for the player, just the standing sprite. Luckily, Overlunky, another community made tool, made it very easy to convert values from the original game for this one. Here is a bomb moving at a speed of -0.250 on the X axis:

1.gif?token-time=1634169600&token-hash=2lYRZeWPB_hUvnyjAjfk6tm89b2S-SHgnDolM2qDpNo%3D

Every four frames, it ends up in the same relative position in the next tile. This meant a speed of 1 would mean moving 1 tile per frame. For the NES version, moving one tile per frame would be 16 pixels per frame because the tile size is 16 pixels. So to convert a speed from the original game to NES, it just had to be multiplied by 16 (and then 256). Why 256? Speeds of say... .5 pixels per frame are possible, and NES doesn't really do decimals the way modern games do. Multiplying the speed by 256 allows for 255 speeds in between 1 pixel per frame and 2 pixels per frame.

It really is that simple. Here's the initial player jump strength from the original game:

1.png?token-time=1634169600&token-hash=U4HCt7VwPKPIEbgk-V9883GBi_vTPk90UHTRMM5RnO8%3D

The jump strength was .180. And here is the value it ended up on for NES:

PLAYERJUMPSTRENGTH = -737;737.28 = .180*16*256

The actual result had .28, but I had to round it off. Interestingly Spelunky 2's vertical movement values (at least as shown in Overlunky) are backwards from how I'm used to seeing them. Usually a positive number moves something down, not up in computer science. This is why my value ended up negative. In any case, just by copying the basic values, it started to feel a lot like the original game:

1.gif?token-time=1634169600&token-hash=5w8LFCtpSyHf5K7dy-ynb5LslK-98Ti-kuyTXU2pdqo%3D

I didn't implement any horizontal acceleration, though. Drop platforms had the right properties set, but nothing read from them. Here Ana can't walk on top of them because she only knows if something is solid or not:

1.gif?token-time=1634169600&token-hash=6M6ed9o9Vcmd0eVEKCnmxUNha5_PEOiyQtxof3Xk9V4%3D

A bit of platforming:

1.gif?token-time=1634169600&token-hash=68ytv2W0E1DFY6i8zGrRpzomWZMKhzOH-NjGiKUdTQk%3D

I implemented actual mantling here: 

1.gif?token-time=1634169600&token-hash=GAVJQjwDiBlL6tbp08Zn7UCZB4dQmYkKfZiSMOl17nE%3D

In the earlier GIF it just made Ana jump in a mantle situation. At this point I was having a lot of fun with playing it: 

1.gif?token-time=1634169600&token-hash=6MywFGzB3euVto-sNmYxuRSiX91e0q5EQbgtOSoBlyY%3D

I implemented a first pass at ladders and what I call the "safe mantle":

1.gif?token-time=1634169600&token-hash=ngGWb0CSUmRXgeX9KbwmsicWqKY4VGLf1_GEpk_cIqk%3D

The safe mantle is when one crawls off a ledge into a mantle. Contrasted by the "unsafe mantle" where one runs off an edge, then steers back toward it while falling. The safe mantle doesn't appear in the video because I didn't have time to make graphics for it (and it also occasionally warps the player upwards). There is some ladder behavior that is also broken. I simply avoided these issues during recording.

I did a mad dash to make a lot of sprite art. All of it except the standing sprite was made from roughly 12 hours before the 15th, to the day of... Still, some of it didn't end up too bad: 

1.gif?token-time=1634169600&token-hash=QGwbNlCrhu5R4S_oJoftLEgngIuaAVpstk6bOai6Bhc%3D

After making all the graphics, the first step was to get them all in the game:

1.gif?token-time=1634169600&token-hash=YQ4f727qz34HacQ8ZtSMwluNbrump_qUJAIqvq8Jluk%3D

You can see some sprites that don't appear in the video if you don't blink! I wrote a program called I-CHR that makes the conversion from standard image formats to NES sprites basically automatic. You can read more about I-CHR here or here. 

After assigning the graphics to the actual actions, it started to look pretty good:

1.gif?token-time=1634169600&token-hash=waNj51iNcMN4YAxyPHeGQCnIPA1MlD_wxFNfl8UqYRQ%3D

But it was also fake, in a way:

1.gif?token-time=1634169600&token-hash=34eFxoq9L2r__QLrZEk-ikGHUUCkywRnQzt3wDEiT44%3D

All that work put into allowing destroying things, and there wasn't time to hook it up to any player action. In the actual game the whip would destroy these tiles. While filming the video, I was actually praying these blocks didn't block progress in the generations!

Overall I ended up making way less progress than I wanted to, but I at least got something up on the day of the anniversary... Still, the time spent on things that didn't get used was worth it. Now I have code for destroying tiles on screen that can be used for other projects.

Changes Since the Video

There were a few things I had to avoid doing in the video to avoid it looking odd. One of them was not mantling a structure under a drop platform:

1.gif?token-time=1634169600&token-hash=12a-ofaV9mhiuo0p1Ja-3EedTr-EgOreykFd35Yi6d0%3D

That has been fixed:

1.gif?token-time=1634169600&token-hash=RZyjJOCgFbKew4yOtYUtdw5-f0h37VmeOCle5pyEkrk%3D

The mantle check was basically, "If the tile to next to me is solid and the tile above that is not solid, it's okay to mantle." But drop platforms are technically solid... the fix just excludes them.

Spelunky 2 lets the player run across one tile wide gaps, but gaps near drop platforms would occasionally let the player fall in the video build:

1.gif?token-time=1634169600&token-hash=-PlEc1qeHNh3jVJfmkmT7JJZ9NiIikTmV-fYHwl988c%3D

This has been fixed:

1.gif?token-time=1634169600&token-hash=WyV8MO91k-9SCt6o1IzVDu8AYR8aw1BsAYNNbF8W_e8%3D

The actual game also plays the walking animation here instead of the fall animation, though...

Someone asked about multiplayer, so I quickly made a test for this:

1.gif?token-time=1634169600&token-hash=nA_V1921yC2pSeQsz-CnLRwSfph_afdgQv_GsuLRD_A%3D

I make no promises about this. It was just easy to do this quick test.

It's also possible to change direction while whipping in the video build. I'm redoing all player movement to be less hacky in general at the moment, and finally adding that horizontal acceleration.

What's next?

So that no one gets too excited, I am not planning on doing the whole game. It will be a taste at best. The original plan was to do 1-1 through 1-3, with no backlayer even. Now the backlayer may find its way in, and 1-4 might find its way in. Anything else is very, very unlikely. There will be things that are missing or different even within the current planned scope. Some things require reverse engineering work I'm not up for to more closely match, and some things would just be fairly taxing to develop. I'm trying to keep the scope light here. I have original projects I would like to work on too. I would also like to avoid creating something people could play in place of buying the actual game. Which, again, you can find on Steam here: https://store.steampowered.com/app/418530/Spelunky_2/

Spelunky 2 was recently updated, which broke compatibility with all the debugging and modding tools I've been using. I am still set up to use them with an older version of the game (many of the recordings from this post were made this way), but it's a little less convenient. Progress may be halted until Modlunky 2/Overlunky are fully back on their feet.

I appreciate everyone's patience and support. These in depth articles take quite some time to put together!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK