A Castlevania-style game for PICO-8
This is a game I wrote to push the limits of PICO-8 as far possible. It features 6 full levels, branching pathways and two difficulty settings. On this page, I'll discuss some of the design decisions I made when creating this game, and how I went about implementing certain features.
Developing this game in PICO-8
Writing this game in PICO-8 required a lot of careful thought and planning. Below, I'm going to go through some of the considerations made during development and how they led to the final game.
PICO-8 is a fantasy console. A fantasy console is basically a development environment that enforces a number of restrictions on development. It does this to emulate a traditional retro games console, but without needing to program games in assembly code. More information about PICO-8, as well a lot of games many people have made in it can be found on the PICO-8 website.
Visually, a PICO-8 game is very distinctive. The screen resolution is fixed at 128x128 pixels, and pixels are limited to a specfic set of just 16 colours. There are also processor and memory limitations, but they're much more forgiving than the older consoles PICO-8 is based on. However, here I'm going to be talking mostly about the limitations that affect programming PICO-8 games.
PICO-8 has 3 main restrictions placed upon the code.
The first is character count. A single PICO-8 game cartridge can be up to 65536 characters. This seems like a fairly high number, but we'll see later how this can become a problem.
The second is token count. A token is almost any unit of code. For example, a variable name, a pair of brackets or an equals sign is a token. A single cartridge can not exceed 8192 tokens. If it does, it will refuse to run. When developing anything in PICO-8, this is the first limitation you'll typically run into.
The third is compressed code size. A cartridge, after compression cannot exceed 15360 bytes. Unlike the other restrictions, PICO-8 will still run a cartridge that goes over this restriction. However, it is not possible to export the cartridge in any way while this limit is exceeded. This seems pretty arbitrary, but this is due to the way PICO-8 cartridges are exported.
The Demon Castle PICO-8 cartridge
This image is the result of the game being exported. The image file actually contains all of the code, the graphics and the sounds that make up the game. If you look carefully, you'll notice small variations in the colours of the pixels in the image. All the data is encoded into the pixel values of this image. If you were to save this image to your computer, you could open it using PICO-8's desktop application and run the game.
One of my main goals for this game was to push PICO-8's limits on game size. In the case of this game, that meant having a large number of levels. To look at the challenges this faced, let's take a look at the way PICO-8 handles sprite and map data.
PICO-8 allocates some space for both map and sprite data. The space available for sprite data is a 128x128 pixel image. Here's the spritesheet used in Demon Castle:
Demon Castle's spritesheet
Each 8x8 section in the spritesheet can be treated as an individual tile that can be used to define map data. The space available for this is 128 tiles wide by 64 tiles high.
However, there is a caveat to using the spritesheet and map data. Both the sprites and the map overlap in memory. Specifically, the lower half of the spritesheet and the lower half of the map are both mapped to the same locations in the console's virtual memory.
Since I wanted to be able to use the entire sprite sheet for defining graphics, that meant that the available space for map data was cut in half. Sharing data with the spritesheet meant that the lower half of the map is filled with garbage data generated from the colours of the pixels in the lower half of the spritesheet.
The lower half of the map is garbage data from the spritesheet
So, knowing that the lower half of the map data is unusable, this is what the map data looks like with the game's first level in it:
The first level in PICO-8's map data memory
Looking at this image, there's an obvious problem: The game has 6 levels, all about this size. Yet one level on its own takes up almost all of the available map space. This meant I had to look into another way of storing the levels.
Loading levels from an external source
Since the map editor in PICO-8 is too small to hold all the game's levels simultaneously, I needed to use an external editor to design the levels. I used the Tiled Editor to make my maps. The Tiled Editor has a lot of useful features for designing maps, but the one I needed to use for this project was simply exporting the map data as JSON.
The first level being edited in the Tiled Editor
Having the map data in text format would let me paste it into PICO-8's cartridge directly. However, there was another problem. If I put the data for all the levels into the code in JSON, I would quickly exceed the character count limit that PICO-8 enforces. I needed to encode the data in some way.
I created a simple Python script to encode the data. I would give it the JSON file as an input parameter, and it would output a stream of characters that I could then paste into my code.
When encoding the map data, I needed to encode it in a way that was easy to decode inside PICO-8. I decided to use the numbers 0-9, the letters A-Z and a-z and ! and ? as my characterset for encoding. I chose these because that makes a total of 64 characters, or 6 bits of data per character. I also used some extra characters, such as / for extra control over data.
Using 64 unique characters would let me represent 64 tiles. The spritesheet contains 256 individual 8x8 tiles, so I needed a way to reduce this.
The spritesheet, split into 4 "graphics banks".
The first thing to consider is the layout of the spritesheet. When displaying in the editor, the spritesheet appears as 4 individual sheets. I'll be calling these "graphics banks". Looking at the layout, it's possible to see that the tiles used for level backgrounds are only in the second and third graphics banks. The other two banks are instead used for other graphics, like characters or bits of the map screen. This reduces our initial 256 possible tiles to 128 tiles.
From this point, a naive solution would be to use the 64 characters to represent the tiles in bank 2, and have a character, like the / character indicate that the next character instead represents bank 3. This soultion would work, but it's possible to do better.
From looking at the banks, we can see that the the tiles are layed out in a somewhat logical way. Most of the tiles in bank 2 are stage elements, like blocks, walls or bridges. Most of the tiles in bank 3 are background elements, like mountains, sky and the moon, or tiles that aren't used too often, like the gravestone and skeletons. This meant that there was some spacial redundancy in the way the tiles are layed out. Most of the time, tiles from bank 2 would be next to tiles from bank 2, and tiles from bank 3 would be next to tiles from bank 3. Because of this, it made sense to use the / character to switch the current bank between banks 2 and 3.
I was able to use spacial redundancy to reduce the size of the map data further. In a lot of places, the same tile is repeated multiple times horizontally. Instead of writing a character for each tile, I introduced the + character to the encoding. The + character indicates that the previous tile will be duplicated. The character directly after the + is converted from a character to a number between 1 and 64. The tile is then repeated that many times horizontally. This one change reduced the size of each encoded level considerably.
If abusing horizontal spacial redundancy worked well to reduce the size of level data, it would make sense to try it with vertical redundancy too. I implemented a similar system to check for matching tiles vertically as well - setting full rectangular areas of matching tiles. The results were... underwhelming. It barely affected the character count, and had no effect on the compressed code size. Since I would also have to use characters and tokens to implement the decoding system, I decided to revert to my previous system of only abusing horizontal spacial redundancy.
Even with all my reductions, there was a limit to how small I could make the level data. One of the levels was removed from the game outright. The following level (titled aqueduct in my files) had to be removed entirely from the game to get under the character limit.
The cut aqueduct level
Removing the level had an effect on the branching pathways of the game. Just like in the final game, there were planned to be three pathways through the game. The aqueduct level would have been along the middle path, but in the final, the middle path joins back with the upper path at that same point.
How the levels connect to each other
To accommodate this change, the level I referred to internally as deepforest was given an alternative starting point. Depending on whether you enter from the forest level or the swamp level now determines where you start in the level.
The aqueduct level wasn't the only level to be left on the cutting room floor. Early on in development, I had plans to include optional side areas which the player could explore for extra rewards. Neither of these even got to the point of being designed as levels - I already knew at that point there wouldn't be enough space for them.
The remnants of the levels cut earliest in development
That's not to say that neither of them made them into the game at all though. The two levels I had to remove were going to be themed as a church and a crypt. In the place where the church would have been is a gravestone, as a small memento of the missing level. The crypt is still in the game, but it's implemented as part of the map screen, instead of as its own level. Opening the crypt was always planned to be the purpose of the key that can be found in the first level.
The final level that had to be cut was a bridge leading to the castle. This would have been a short level building up to the final one, and would have been different depending on the path taken to get there. It was cut fairly early on due to adding little to the game. It was instead incorporated as part of the final level, and branching paths leading to the castle were implemented in the final level itself.
Even after all my cuts and reductions, there was still not quite enough room for everything I wanted on the cartridge. I thought about this for a long time - I didn't want to cut any more content than I already had, and I could see no way to reduce the size of the data by any considerable amount. Eventually, I found a loophole. The limit I was struggling with at the time was compressed code size. I discovered that changing the game's map data did not affect the compressed size of the cartridge, since PICO-8 already allocates space for it. I realised that, since the first level would always be the first to be played, I could manually draw that level into PICO-8's map data. That way, I wouldn't need to store the level's layout in the rest of the cartridge. This workaround gave me just enough space to put all the content I wanted into the game.
Enemies and Bosses
In the previous section, I spoke extensively about how I dealt with the character count and compressed code size limits. That's not to say the token limit was any less of a consideration.
Outside of the player code, a large portion of my code was dedicated to enemy programming. I wanted to have a variety of enemies, but I didn't have that much space to implement them.
My solution was to use a considerable amount of code duplication. For a start, all non-background elements of the code inherit from an class I called actor. The actor class has a lot of properties that all objects share, like position in x and y within the level. It also has functions, like update and draw which are called each frame. All the other game objects override the update function with their own code, but most game objects rely on the default implementation of draw, which draws a sprite at the actor's position. They aren't used by all instances of actor, but functions to be affected by gravity and momentum were also implemented. This saves a lot of tokens compared to implementing these functions wherever they were needed.
One subclass of actor is enemy. The enemy class implements a few more functions specific to enemies, such as for hitting the player, or for getting hit. It also implements a few useful properties for enemies, such as their health and damage they do. As you would expect, all enemies in the game inherit from the enemy class.
In addition to the game's regular enemies, I needed to implement bosses. To save tokens, most bosses in the game inherit their behaviour from a regular enemy. By making minor tweaks to each enemy, such as giving them more health and making them bigger, I was able to implement an impressive number of bosses. One boss, the axe-throwing armour, doesn't directly inherit from an enemy, but takes elements from them, like how its legs work from the zombie enemy. Additionally, the axes it throws are derived from the fireballs shot by other enemies.
The bosses of the game with the enemies they are based on
Despite the number of enemies and bosses I was able to implement, some repetition was inevitable. To reduce the impact of the repetition, I used the time-honoured tradition of palette-swapping the enemies. For each level, I defined a series of colours to use for all the enemies and the boss. This didn't really add anything functional to the game, but it gave each level a bit more variety.
When I was designing this game, I really wanted to make replayability a key feature. This is why I implemented the branching paths between levels, or the hidden items that could be found in each level.
Another way I added replay value was with the implementation of a difficulty system. At the title screen, the player is allowed to pick between Normal and Hard difficulty. I explicitly avoided calling the easier difficulty "Easy", because that was the difficulty I designed the game to be played at for the first time, and I didn't want anyone to feel bad for playing on "Easy" and inadvertedly giving themselves a worse experience.
In terms of implementing the difficulty, there were a number of options I could have taken. The simplest would have been to reduce the player's health, or to make enemies do more damage. However, these feel like cheap changes which do little to actually make the game harder, just less forgiving. From an early stage of planning, I wanted to avoid these kind of difficulty changes.
For the Hard mode of this game, I reduced the player's starting health from 6 points to just 4. In addition to this, however, I also designed a system that would allow me to place more enemies into each level in the harder difficulty. This allowed me to make some pretty drastic changes to level layouts. Some enemies barely appear at all in Normal mode, but are a common feature throughout Hard mode.
The same area on the Normal and Hard difficulties
One additional change I made was to the checkpoint system. Right from the start of development, I had decided against using a lives system. In addition to being an outdated design feature, they are ill-suited for a browser game, where a player can get bored and close the game from the smallest of frustrations.
For a similar reason, I implemented checkpoints throughout each level, specifically whenever the player went up or down stairs to a new screen. This small feature made the game much more accessible to a lot of players. A lot of people who I asked to test the game hadn't played too many games before, but only being set back a short way prevented them from becoming discouraged when playing. In the Hard mode, however, I removed these checkpoints. This makes the game feel more similar to the less forgiving games it was based on, and forces a player to beat the whole level on just one life bar.
Testing the two difficulties took up a large bit of my development time. There were a few instances where the difficulty on Hard mode was just too much, to the point where it was just hostile to a player. Even on hard mode, I never wanted the game to feel impossible, or unfair, so I spent a lot of time tweaking enemy positions for both difficulties.
Although generally, I am really happy with my finished game, there are a few things that in hindsight I would have done differently.
Throughout the game, I hid a number of golden orbs, known as stones of sealing. Whichever path a player took through the game, it would be possible for them to collect three of them. Depending on whether the player took the time to collect them all would determine whether they got the "good ending" or the "bad ending".
I don't fundamentally have a problem with this system - having the collectables hidden on each route was just another form of replayability. However, some of the details of how I implemented this left something to be desired.
For a start, the reward for collecting all the stones was not really worth the effort. All that changed was a slight change in the death animation for the final boss, and some different text and music on the end screen. I'd have loved to implement a "true final boss" or a secret extra level as a reward for going out of your way to find the hidden stones, but by the time I would have done this, I was so hard-pressed against the limitations of the fantasy console that there was no way I could have done this.
Additionally, I felt like I did a poor job of conveying the reason for a player getting the "bad ending". I implemented an animation where the stones the player had collected would float out of their character before turning grey and falling to the ground, but I don't feel that it was particularly clear. I had some players who were testing it trying to collect the stones as they floated out of reach, and if you didn't collect any, the animation wouldn't play at all.
The stones animated around the final boss
I eventually settled for adding a counter for the number of stones collected on the ending screen, showing the number out of three that the player had collected, as a clue for what they needed to do.
The ending screen with the number of stones collected
My final issue with this system was the way the stones are hidden. Most stones are hidden behind breakable walls, with players expected to check walls that looked suspicious to them by attacking them. The problem with this system is that it relys on players being familiar with this kind of game, and the idea of searching for secrets. For a lot of people, this was quite an alienating idea, and I could have done a better job hiding them more fairly.
A breakable wall, hiding a secret
During planning, I did have a system in mind to counter this. After the ending screen, I wanted to take any player who got the "bad ending" to a screen that showed a location they'd visited in the game, as a hint that something was hidden there. This would have shown players where they needed to look without having to search everywhere, and given new players a hint of what they should have been looking for. This system, however, had to be cut for space reasons, and I never found a suitable replacement.
However, this system wasn't entirely without merit. After posting the game publically, I had a lot of comments from people showing off how many stones they'd managed to find, or posting their times for reaching the best ending. So although I recognised that I could have implemented aspects of this system much better, I am still glad I implemented it.
My final thoughts
All in all, I'm incredibly satisfied with this project. I feel like I successfully pushed PICO-8 beyond its basic limitations and created something pretty impressive in it.
I'm also really pleased by the reaction this game had after posting it online. Between posting it on itch.io and on the PICO-8 website, I recieved a lot of really positive comments about it, with many people sharing screenshots of their experiences. The game even got picked up by a few other websites and blogs, including one that wasn't even in English. It makes me extremely happy to see people playing and enjoying the game I made.