Cherry Bomb Postmortem

87

This 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:

benchmark.js

_25
const entities = [...Array(100_000).keys()].map((x) => ({
_25
id: x,
_25
position: {
_25
x: randomInteger(0, 100),
_25
y: randomInteger(0, 100),
_25
},
_25
}));
_25
_25
// Map<number, Entity>
_25
const entitiesById = new Map(entities.entries());
_25
_25
// Map<Entity, Entity>
_25
const entityMap = new Map(entities.map((x) => [x, x]));
_25
_25
function iterateById() {
_25
for (let i = 0; i < entities.length; ++i) {
_25
entitiesById.get(i);
_25
}
_25
}
_25
_25
function iterateByObject() {
_25
for (let i = 0; i < entities.length; ++i) {
_25
entityMap.get(entities[i]);
_25
}
_25
}

There's nothing to impressive to see here, but the performance difference is unreal.

benchmark.js

_10
iterateById(); // 666 ops/s

benchmark.js

_10
iterateByObject();(); // 32,912 ops/s

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
_18
objecs (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:

local-transform-system.ts

_23
import { World } from "objecs";
_23
import { Entity } from "../entity.js";
_23
_23
/**
_23
* Position entities relative to their parent.
_23
*/
_23
export function localTransformSystemFactory({
_23
world,
_23
}: {
_23
world: World<Entity>;
_23
}) {
_23
const children = world.archetype("localTransform", "parent", "transform");
_23
_23
return function localTransformSystem() {
_23
for (const entity of children.entities) {
_23
entity.transform.position.x =
_23
entity.parent.transform.position.x + entity.localTransform.position.x;
_23
_23
entity.transform.position.y =
_23
entity.parent.transform.position.y + entity.localTransform.position.y;
_23
}
_23
};
_23
}

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:

track-system.ts

_11
export function trackSystemFactory({ world }: { world: World<Entity> }) {
_11
const children = world.archetype("localTransform", "parentId", "transform");
_11
_11
return function trackSystem() {
_11
for (const entity of children.entities) {
_11
const parent = world.getEntityOrThrow(entity.parentId);
_11
_11
// same code as before...
_11
}
_11
};
_11
}

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:

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:

main.ts

_40
const loadingScreenScene = new LoadingScreen({
_40
/* deps */
_40
});
_40
loadingScreenScene.on(GameEvent.StartGame, () => {
_40
activeScene = activeScene.switchTo(titleScreenScene);
_40
});
_40
_40
const titleScreenScene = new TitleScreen({
_40
/* deps */
_40
});
_40
titleScreenScene.on(GameEvent.StartGame, () => {
_40
activeScene = activeScene.switchTo(gameplayScene);
_40
});
_40
_40
const gameplayScene = new GameplayScreen({
_40
/* deps */
_40
});
_40
gameplayScene.on(GameEvent.GameOver, () => {
_40
activeScene = activeScene.switchTo(gameoverScene);
_40
});
_40
gameplayScene.on(GameEvent.GameWon, () => {
_40
activeScene = activeScene.switchTo(gameWonScene);
_40
});
_40
_40
const gameoverScene = new GameOverScreen({
_40
/* deps */
_40
});
_40
gameoverScene.on(GameEvent.StartGame, () => {
_40
activeScene = activeScene.switchTo(titleScreenScene);
_40
});
_40
_40
const gameWonScene = new GameWonScreen({
_40
/* deps */
_40
});
_40
gameWonScene.on(GameEvent.StartGame, () => {
_40
activeScene = activeScene.switchTo(titleScreenScene);
_40
});
_40
_40
let activeScene: Scene = loadingScreenScene;
_40
activeScene.enter();

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

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:

pico-8_font.xml

_10
<?xml version="1.0" encoding="utf-8"?>
_10
<Font style="Regular" height="5" family="PICO-8" size="5">
_10
<Char width="4" offset="0 5" rect="1 6 0 0" code=" "/>
_10
<Char width="4" offset="1 0" rect="2 1 1 5" code="!"/>
_10
<Char width="4" offset="0 0" rect="4 1 3 2" code="&quot;"/>
_10
<Char width="4" offset="0 0" rect="8 1 3 5" code="#"/>
_10
<Char width="4" offset="0 0" rect="12 1 3 5" code="$"/>
_10
<!-- ... -->
_10
</Font>

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:

pico-8_pal.lua

_11
for i = 0,15 do
_11
-- Replace all colors with white
_11
pal(i, 7)
_11
end
_11
_11
-- Draw the sprite
_11
-- Order is sprite index, x, y
_11
spr(0, 0, 0)
_11
_11
-- Reset the palette
_11
pal()

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:

press any key to startplayer ship

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
_34
const bufferCanvas = document.createElement("canvas");
_34
const buffer = bufferCanvas.getContext("2d");
_34
_34
// Nice chonky pixels
_34
buffer.imageSmoothingEnabled = false;
_34
_34
// Set the size of the buffer to the size of the sprite
_34
// playerShipImage is a preloaded Image object
_34
bufferCanvas.style.width = `${playerShipImage.width}px`;
_34
bufferCanvas.style.height = `${playerShipImage.height}px`;
_34
_34
// Make sure we're at a clean slate
_34
buffer.clearRect(0, 0, bufferCanvas.width, bufferCanvas.height);
_34
_34
// First we draw the sprite to the buffer, which is using `source-over`
_34
buffer.drawImage(playerShipImage, 0, 0);
_34
_34
// Now set the color to the tint color desired
_34
buffer.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
_34
buffer.globalCompositeOperation = "source-in";
_34
_34
// Draw a rectangle over the entire buffer (sprite) to tint it
_34
buffer.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`
_34
buffer.globalCompositeOperation = "source-over";
_34
_34
// Draw the buffer to the main canvas
_34
context.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:

circFill.ts

_35
function circfill(
_35
context: CanvasRenderingContext2D,
_35
x: number,
_35
y: number,
_35
r: number,
_35
color: string,
_35
) {
_35
let x1 = 0;
_35
let y1 = r;
_35
let d = 3 - 2 * r;
_35
_35
while (x1 <= y1) {
_35
// Draw horizontal lines of the circle
_35
for (let i = -x1; i <= x1; i++) {
_35
context.fillStyle = color;
_35
context.fillRect(x + i, y + y1, 1, 1);
_35
context.fillRect(x + i, y - y1, 1, 1);
_35
}
_35
_35
// Draw vertical lines of the circle
_35
for (let i = -y1; i <= y1; i++) {
_35
context.fillStyle = color;
_35
context.fillRect(x + i, y + x1, 1, 1);
_35
context.fillRect(x + i, y - x1, 1, 1);
_35
}
_35
_35
if (d < 0) {
_35
d = d + 4 * x1 + 6;
_35
} else {
_35
d = d + 4 * (x1 - y1) + 10;
_35
y1--;
_35
}
_35
x1++;
_35
}
_35
}

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:

circ.ts

_33
function circ(
_33
context: CanvasRenderingContext2D,
_33
x: number,
_33
y: number,
_33
r: number,
_33
color: string,
_33
) {
_33
let x1 = 0;
_33
let y1 = r;
_33
let d = 3 - 2 * r;
_33
_33
while (x1 <= y1) {
_33
// Plot points along the circumference of the circle
_33
context.fillStyle = color;
_33
context.fillRect(x + x1, y + y1, 1, 1);
_33
context.fillRect(x - x1, y + y1, 1, 1);
_33
context.fillRect(x + x1, y - y1, 1, 1);
_33
context.fillRect(x - x1, y - y1, 1, 1);
_33
context.fillRect(x + y1, y + x1, 1, 1);
_33
context.fillRect(x - y1, y + x1, 1, 1);
_33
context.fillRect(x + y1, y - x1, 1, 1);
_33
context.fillRect(x - y1, y - x1, 1, 1);
_33
_33
if (d < 0) {
_33
d = d + 4 * x1 + 6;
_33
} else {
_33
d = d + 4 * (x1 - y1) + 10;
_33
y1--;
_33
}
_33
_33
x1++;
_33
}
_33
}

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.