Cherry Bomb Postmortem
87This post describes what I learned and encountered porting a PICO-8 game Cherry Bomb - which you can play here, to the web using my own Entity Component System (ECS). You can find the original tutorial series on YouTube. Checkout the channel Lazy Devs. It's a great series and I find this guy hilarious.
The reason I chose this game is because it's relatively simple and complete, but more importantly the assets and game design were already done. I didn't want to spend too much time on that. I just wanted to focus on porting and using my ECS in a real project.
I'll talk about some ECS stuff first, then some more about the canvas.
A Different Level of Speed
My original ECS heavily used a Map<EntityId, Entity>
structure to store entities by their Id (in this case just a number
). This is a pretty logical choice, as it's a simple way to get a reference to an entity by its Id. However, it's not the fastest way to do so.
Let's take a look at some code below that compares the performance of iterating over an array of entities and getting them by their Id vs. getting them by their object reference:
There's nothing to impressive to see here, but the performance difference is unreal.
Holy smokes! That's a ~50x speedup! I'd love to say I know exactly why this is. I know Map.prototype.get
uses what's referred to as SameValueZero
for key comparison, but my best guess is with objects we're seeing a pointer comparison vs actually executing the SameValueZero
comparison logic. The pointer comparison should be very fast since it's just comparing memory addresses.
You can run the benchmark for yourself on perf.link.
Here is a benchmark of my old ECS vs my new ECS:
_18@jakeklassen/ecs (old)_18 packed_5 2,767 op/s_18 simple_iter 1,492 op/s_18 frag_iter 4,789 op/s_18 entity_cycle 741 op/s_18 add_remove 2,299 op/s_18_18objecs (new)_18 packed_5 106,763 op/s_18 simple_iter 82,742 op/s_18 frag_iter 21,467 op/s_18 entity_cycle 2,842 op/s_18 add_remove 6,156 op/s_18_18| op/s | packed_5 | simple_iter | frag_iter | entity_cycle | add_remove |_18| ---- | --: |--: |--: |--: |--: |_18| @jakeklassen/ecs | 2,767 | 1,492 | 4,789 | 741 | 2,299 |_18| objecs | 106,763 | 82,742 | 21,467 | 2,842 | 6,156 |
I used the noctjs/benchmark repo and node.js 20.2.0
for these numbers.
My new ECS, which I called objecs
(object ECS), uses this approach for a significant performance boost. You can find it on GitHub and on NPM.
Relationships
When you use something like Unity or Godot, the natural and visual hierarchy of entities is nice to use. It's usually presented visually in the editor as a tree. This has the benefit of local transforms: a child can be positioned relative to its parent. While I didn't have much need for it in this game, I felt it immediately when I encountered the player ship and thruster.
The player and thruster are separate frames in the sprite sheet, and yes I could have just merged them and duplicated the player frames where needed, etc. I wanted to try this instead. It resulted in some funny bugs: I'd kill the player, but forget to destroy the thruster too so it would still be there animating, or moving.
I ended going with a simple system
(the S
in ECS) that would allow an entity track a parent entity. Here's the code:
At the time of this writing, this is the actual file from the game. It's pretty straightforward. I have a localTransform
component that has a position
(vector 2d) property. The parent
component is the entity we want to be positioned relative to. We use these in tandem to determine the entities final transform
. I probably should have just called it playerThrusterSystem
or something, and it's good to sound generic right?!
I might refactor this if reimplement entity Id's again. I removed them in objecs
to wait until I felt the need for them again. It could look something like this at that point:
Or something along these lines.
Another option would be to mess with a scene graph. I'd want to make sure I keep the ECS and scene graph in sync, and separate so they are unaware of each other.
Scenes
It didn't take long and I reached a point where I wanted scenes. I ended up with a few different ones, including:
- Loading screen
- Audio assets are loaded here and first interaction is awaited to kick off the
AudioContext
- Audio assets are loaded here and first interaction is awaited to kick off the
- Title screen
- Gameplay screen
- Game over screen
- Game won screen
They are classes that extend a base Scene
class. The fun part was that every scene get its own world
so they are very self contained with regards to entities. The game over screen literally captures a frame from the game play screen and renders that constantly along with some additional info.
I had the scenes emit events as well, while I always tracked the active scene. A bit like a state machine with enter
and exit
transitions. Here's how my scenes are setup as an example:
I thought this worked out pretty well, and solved my issues.
Oh Canvas
Let's move onto what I encountered during the porting of the game to use the HTML canvas. I used a 2d context for this game.
There were a number of problems I needed to solve along the way, including:
- Bitmap fonts
- When I say bitmap here I mean a font intended to be crisp and pixelated, like retro game fonts.
- Crisp, pixel-perfect filled and stroked circles (
context.arc
)- These correspond to the PICO-8
circ
andcircfill
functions - I used ChatGPT for almost the entire implementation of these
- These correspond to the PICO-8
- Sprite tinting
Bitmap Fonts
I should preface this by saying I mainly developed this with Chrome in mind. If I ever released this to Steam, I'd just use electron or some wrapper around v8 anyway. Or just not go with the web at all and not have this problem haha.
On Chrome, fonts are aliased or blurred or something when drawn to the canvas. I've submitted a Chromium bug report about this, but according to them it's as expected. I'm not sure why, but I'm not going to argue with them, so I had to find a workaround.
Here's an example of the issue:
Load that in Firefox and it renders nice and crisp.
The solution is literally to treat fonts like bitmaps (images) using a sprite sheet. I used a tool called BMFont to take a .ttf
file that I found on GitHub and generate XML for parsing, and a sprite sheet.
Here's an example of how that XML looks:
At this point I have what I need to read portions of the sprite sheet depending on the characters I need.
I won't go into gory details beyond this, but I basically map strings of text I need in the game to prerended canvases that I then draw to the main canvas. If you didn't know, you can draw a canvas to another canvas using context.drawImage
. Very handy. These are stored in a cache of type Map<string, TextBuffer>
. TextBuffer
is a wrapper class I made to handle the details.
What about color?! I hear you scream. Let's talk about sprite tinting next.
Sprite Tinting
PICO-8 has a really cool pal()
function that can palette swap colors before you draw a sprite. As an example, you might loop all the colors (0-15) and replace them with almost white (7) for a flash effect:
We have no such function in the canvas API, so I had to take a different approach.
There are a few ways to solve this, but I went with a buffer canvas approach because I like how easy they are to draw to another target canvas via context.drawImage
.
The trick here is to make use of the context.globalCompositeOperation
property. This property determines how the pixels of a source image are blended with the pixels of a destination image. The default value is source-over
, which means the source image is drawn over the destination image. This is what we want for most cases, but we want to use source-in
for tinting.
The MDN docs have a great chart of this property's values and what they do.
I'll show two examples here:
- Tinting a player ship sprite to white, for say a flash effect
- Tinting a sprite of white text characters to any color I need, red in this case
Here's some example code of using a buffer
canvas to draw the final result to the main canvas:
_34// Create an in memory canvas_34const bufferCanvas = document.createElement("canvas");_34const buffer = bufferCanvas.getContext("2d");_34_34// Nice chonky pixels_34buffer.imageSmoothingEnabled = false;_34_34// Set the size of the buffer to the size of the sprite_34// playerShipImage is a preloaded Image object_34bufferCanvas.style.width = `${playerShipImage.width}px`;_34bufferCanvas.style.height = `${playerShipImage.height}px`;_34_34// Make sure we're at a clean slate_34buffer.clearRect(0, 0, bufferCanvas.width, bufferCanvas.height);_34_34// First we draw the sprite to the buffer, which is using `source-over`_34buffer.drawImage(playerShipImage, 0, 0);_34_34// Now set the color to the tint color desired_34buffer.fillStyle = "#FF004D";_34_34// Change the operation to `source-in` so that the tint color is only applied_34// to the pixels of the sprite, everything else is transparent_34buffer.globalCompositeOperation = "source-in";_34_34// Draw a rectangle over the entire buffer (sprite) to tint it_34buffer.fillRect(0, 0, bufferCanvas.width, bufferCanvas.height);_34_34// If you plan to do more with the buffer, you should set the operation back_34// to `source-over` so that the next draw operation doesn't use `source-in`_34buffer.globalCompositeOperation = "source-over";_34_34// Draw the buffer to the main canvas_34context.drawImage(bufferCanvas, 0, 0);
There you have it, some basic sprite tinting.
Circles
Ah, circles. I totally forgot that the canvas arc
method performs some aliasing when you draw a circle, filled or stroked. I wanted to maintain the nice crisp look of circ
and circfill
in PICO-8, so I had to find a workaround.
I had seen some examples of this on StackOverflow, but I wanted to use this opportunity to see what ChatGPT could do. It wasn't my first time using it, but I was really happy how fast I came away with something workable.
Here's was my initial prompt:
Can you give me a full implementation of the PICO-8
circfill
in TypeScript using the HTML canvas?
I won't paste the snippet it gave me intially, but it wasn't quite right. It kept an odd boxy shape. So I followed up with:
This is not the same as PICO-8. The circles retain a more square like shape.
It's response was:
And a bit of context as well:
This implementation uses Bresenham's algorithm to draw the circle by iteratively plotting points along the circumference of the circle. It also fills the circle using fillRect function but plots the pixel coordinates more carefully to generate a more accurate circle.
This was perfect!
I think asked for circ
as well:
Can you give me the implementation for PICO-8
circ
as well. It would be the same circle, just not filled.
And it gave me this:
Here's a comparison of canvas vs PICO-8:
Lovely!
Fin
I think that's a pretty good place to wrap things up. I'm really happy with how this turned out. I'm also really happy with how ChatGPT performed. I think I'll be using it more in the future.