Chroma Crystal: Episode IV: The Tarnished Prince and the Pearlescent Heroine

This game was the first time I'd ever worked on a game jam, and it was the first "finished" project I made in Godot. So with that in mind, I'm unbelievably proud of what I accomplished. This game was for the Metroidvania Month x Magical Girl Game Month dual jam. My main goal going into this was just to learn as much as I could and get something "finished", even if it's not perfect.

Beginning: Week 1

Before the jam started, I was already brainstorming a little bit of what kind of game I could make. My brother and I have discussed the concept of top-down 2D Zelda style (or even 3D style, just in general not-side-scrolling) Metroidvanias in the past. To me, the most critical part of the genre is the "lock and key" style exploration of an interconnected world, moreso than side-scrolling or shooting or anything like that. That kind of gave me a starting point at least so I knew what I roughly wanted to make. I also thought a lot about the "Magical Girl" theming - a lot of my favorite anime when I watched anime regularly were MS ones like Card Captor Sakura and Madoka Magica, so before the jam I also started to look into other examples of the genre, again with the idea of thinking what "makes" the genre what it is. I settled on color playing a major role, since most Magical Girl shows have color-coded main characters and even often villains. Once the jam started and I read the themes, I was able to put that together pretty well by representing the magical girl's power sources as colored gems, and using the "Pearl" theme to come up with a colorless white gem for the playable character, so that was pretty quick and easy. I figured the gameplay could revolve around collecting colored gems, then changing colors to fight or solve puzzles.

The first order of business for me was to create some art work. I don't have a ton of artistic experience in general, and basically none making pixel art, so this was already a massive undertaking by itself. I knew that for a game jam it would be more important to just get something that works before I worry too much about making it look good, so that's what my mission was. I wanted to have by the end of the first week at least a character that I could run around with, and a few tiles that I could use as the basics of a tileset. I did this by examining GB/GBC/GBA, SNES, and NES games and finding cool tilesets to use as inspiration. I think the orange biome in the end game was based on a sub-area in Breath of Fire and the green biome I pulled from a mix of an area in Final Fantasy V and an area in one of the Dragon Quest games. Everything was hand-drawn, I didn't copy anything directly, but I was able to look at the existing tiles and break down how they were constructed, so I didn't have to think too hard, just execute my own take on them.

Once I had the tiles, I loaded everything up into Godot using TileMapLayers and TileSets. I had watched a brilliant tutorial in the month before the jam (this one: https://youtu.be/0gVdAPjjGmo?si=mgR3zdE2RVLtqiYZ), so I already knew how to set up TileMaps and Collision layers - but put a pin in the collision stuff because we'll come back to that later. I also set up a basic character controller in a similar way, for now just with directional movement, so I could see what the room tiles and character look like together in action.

I next wanted to build up a system to let me traverse from room to room, so I set up a doors object that I could use for that purpose. This was a tricky bit and with everything I have learned since week 1, I realize now that my implementation was very very lacking. Basically, I created an autoload that acts as a room manager. When the game loads, it loads a room and puts the player in the newly loaded room. Then, when the player collides with a door, the door calls a room manager "room transition" function. Each door had 4 main properties: its own ID, the path to the scene for the room to be loaded, the ID for the door in that room which it connects to, and then a spawn offset to tell the room manager where to stick the player after the room is loaded (later, I added a 5th param for global respawn, but I'll talk about that later). For testing, this worked okay, but there were a few issues that wouldn't crop up until waaaay later in the process.

The room controller also handles updating the camera's bounds, which was fortunately a thing built-in to Godot. Way later I added an invisible CanvasLayer node that I could use to set the extents of a room graphically, but at the start I just hard coded it into the room's script.

For color changing, I wrote a simple shader that just replaces pixel colors following basically a thesaurus. In version 1 of this shader, I hard coded in the base colors of my character's sprite and then wrote a gdscript script that could load the "destination colors" into some uniform vec4s, and the shader just replaces the base color with the uniforms according to a particular pattern. Way later I adjusted this so that both the base color and the destination colors could be set, so I could use the same shader for any object that I wanted to change colors of, but this was a good start!

Lastly in week 1, I figured out how to do interactables (i.e. something like a chest in Zelda, you walk up to it and press a button to interact), but I didn't end up using it in the end game. And then also this week, my brother taught me how to use git and set up the repo for me.

Week 2

It's funny going through the history and realizing how far back it was that I first added this, but in week 2 I added an inventory system. This was extremely unhinged - I had no idea yet that Godot offers a built in "focus" thing for UI elements, so I ended up writing my own code to handle selection, menu navigation, and also a cursor. Now I know for my next project and that should make menus much easier to handle. But what I did wasn't too bad, I just found whatever the smallest dot product was betwen a pressed direction and the nearest button that I wanted to be selectable (I used a group to flag buttons for this), and then I changed which button I was pointing at. A bit silly and overdesigned but it worked. I also learned how 9patchrects work which is pretty neat.

As part of this, something else completed in week 2 was the pickups system. I added an autoload to help me manage both game state (in terms of, tracking which items have been picked up etc.) and to help me display items in the inventory (thus tracking the player's inventory: this is that "model-view-controller" separation thing common in software dev). I think I could have handled all of this much better and in a more organized way, but it worked okay anyway. The player could collide with an object and a message would display (just show/hide a banner with text that can be updated), and I set up an Audio manager autoload to play a fanfare when an item is obtained (the only sound that made it into the final game!).

In week 2, I also resource-ified a few things like items and the color shader params, so that godot would load them as resources rather than just a bunch of variables in a script.

Lastly, this week I also put together a snow shader - just generates white particles that attach to the camera and move down the screen, and display until the player gets the pearl power up.

This week was mostly under-the-hood systems. Next week is when things really picked up.

Week 3

One of my first orders of business in week 3 was to make it so the player can take damage. I started with damaging floor spikes, which I based the sprite on spikes from the LoZ: Oracles games. This wasn't too bad - I just used a couple fixed timers that would change the frame, and enable/disable collision monitoring. Here is also when I started to plan out how collison layers work, and I realized with hindsight that this is the kind of system you should plan from the start. But anyway, the player monitors collisions with objects on layer 2 (damaging objects layer) or 4 (enemies layer) and then takes some knockback and damage. I tried to separate knockback from player movement, so I used all the velocity params in the CharacterBody2D node and just added a movement offset while taking knockback, which decayed to 0 over a short span of time. This code ended up re-used so that the player could attack enemies who would take knockback.

There was some weird separation of responsibilities that I didn't handle well, because the damage source was responsible for having a knockback param, but actually I think I should have made it more flexible (so bosses would take less knockback, etc.) This sort of issue was compounded by the fact that I don't fully understand ECS programming, though near as I can tell what I should have done was created a "knockback" or "damage" script that I could attach to a child Node of any entity that could be knocked back, and then using signals link those up to the higher level objects... instead I just copy/pasted the code from the player script to the base enemy or damaging object script.

Anyway, I continued down this line of operation and created an enemy script (state machine to walk in a random direction, basically) and a shooter (object that spawns arrows or bullets when you walk close, the arrows/bullets automatically move and get destroyed when colliding with something). In the end this wasn't too had to implement. I had the interface to the player node already for taking damage so it kind of all just worked.

Enemies also needed to take damage back though, so I had to add an attack to the player at this point. This was just a sprite and collider that are default hidden/disabled, and when you press a button it plays the attack animation and enables collision, once the animation ends it hides it all again. I added tracking to the player movement so I knew where to rotate/mirror the attack to, this wasn't too bad to implement.

Some time in this week, I also started working on adding some more overworld objects to the sprite pool. I made a building, trees, torches, and a bridge, which I used in various places in the end game.

Ending: Week 4

At this point, I had to wrap things up. One critical feature that hadn't made it in yet was dashing and pitfalls. I knew I wanted to design levels around having "platforming" in 2d, where you had to aim a dash to move around. I ended up sort of working on level design first, creating most of the White biome on the Monday of this week, but in order to test my layout I had to add pits and dash. I used yet another collision layer for this, and this is when I had to go back and sort of overhaul my tilemap system. I had been having issues with Y sorting throughout the development, so what I did was made 3, sometimes 4 TileMapLayers per room: the invisible collision layer, which marked the location of pits and walls; the visible base layer which had floor and wall tiles; an objects layer which had stuff like trees and buildings which was Y sorted with active entities, and an optional second objects layer for stuff like the torches on the wall which I didn't want Y sorted with because they had to be drawn on top of the walls.

Clearly,layers and collisions were really messy in this game and I think it's something I want to dive deeper into "best practices" for before my next project. I had a lot of issues getting collisions to work right, since keeping track of which entity was responsible for monitoring the collision and which was supposed to be on which layer was tricky.

A lot of stuff this week was also brute forced - for example, falling into a pit just disables input and collision processing on the player node until the falling animation finishes. It works, but I do feel like there's probably a more elegant way to handle things.

Anyway though, at this point my mission was just to create the map and add some puzzles. I built the in-game switches, which watch for the player's attack hitbox, then emit signals when pressed. I wrapped those in simple scripts to track which switch was pressed and when so I could add switch order based puzzles, or timer based puzzles. Not too complex here. Similarly, I realized I could write a script to track enemy deaths to do the same thing, which wasn't too bad.

I also created the respawn system this week. The player keeps track of the last spawn location, so when falling into a pitfall she can respawn there after taking damage. Similarly, I have a "global respawn" variable that the room loader tracks, and when the player dies she can signal the room loader to respawn at the last global respawn. Simply added a flag to some doors which should be considered global respawns.

Once I had the global respawn setup, it wasn't too much more work to add a main menu to load the game, as well as to save and load progress (which was just done by storing the gamestate dict and reloading it, then having functions in all the autoloads to restore their state based on this). Basically, the main menu will tell the room loader to load a new game or to load the saved game, either way it just needs to have a reference to some room/door for spawning, and the ability to load a room without an input door. This was another bit of bad coding practice as I just copy-pasted the main load command and edited it, rather than properly separating this out into functions. I know for next time it's worth doing this, as any time I wanted to change stuff later (for fluff like the fade in/out between rooms) I had to edit it in both places!

Anyway though, at this point all that was left was to put stuff together into a game. I added the dash, and used a simple particles effect to make it look nice, I built the map and some puzzles in a flurry that I barely remember what I did, I scattered in enemies and traps completely randomly and haphazardly. I had a really basic idea of what the game progress should be (i.e. what puzzles, what locks/keys there are, etc.) so it was just a matter of implementing them... and I did.

One issue that popped up while doing this goes all the way back to week 1. First of all, it was a pain to have to track all of my door IDs, room paths and offsets. I kept connecting the wrong rooms, or putting in my offsets wrong and ending up out of bounds or in a wall. It was also possible to double-hit doors: since the new room loads in before the player object moves, it's possible that the new room has a door in the same position as in the origin room. This then triggers ANOTHER room load and you skip past a whole room this way. I'm sure there was a way in code to prevent this from happening, but ultimately what I did was just make sure all my room layouts did not have a door in the same location as an adjacent room.

Post release

I realized after release when a friend played the game and provided me some feedback that actually feedback and criticism weren't going to be super helpful. There's so much low-hanging fruit and obvious shortcomings of the game that I don't really need people to point stuff out that could have been fixed. There's a ton of engine builtins that I probably didn't know about, I didn't reuse code as much as I could have. But, I know a lot more about Godot now than I did 5 weeks ago, so going into my next project I'll be able to hit the ground running!

The thing I think I did consistently right is to keep things as simple as possible. Most of my functions are like 3-5 lines max (except the ones in the room loading script which is kind of involved...). Most objects do one simple thing. Many scripts have <50 lines of code so are easy to parse (except the player controller which has like 500). I think it's easy to build out modular systems in Godot and this proved it to me.

I'm also really proud of myself for finishing stuff like the main menu. It's a nice bit of polish, and really makes it feel like a "real game" instead of just a demo.

back to projects