Creating a game in the style of GameBoy in 13 KB
Last year I decided to participate in the js13kgames game jam. This is an annual competition to create a JavaScript game from scratch, lasting one month, that must fit in 13 KB (in a zip). It doesn’t seem like a lot of space, but with enough creativity, you can achieve a lot with such limitations. Just take a look at some great examples from past years:
Although my game didn’t rank as high last year, I’d still like to share some of the discoveries I made while developing it.
I wanted to make a game reminiscent of the retro era of games on portable consoles with their unique square screen, low resolution and top-down view. I decided to implement a fast-paced action-RPG style gameplay with simple yet addictive gameplay that motivates the player to keep playing. With the music, everything was obvious – the sound effects should be similar to the sounds of arcade machines.
You can play my game on the Gravepassing page of the JS13KGames website. The complete code is posted on GitHub.
In this article, I’ll talk about the development process, the various components, and the challenges I encountered along the way.
Contents
Sources of inspiration and graphics
When creating the graphic style, I focused on a mixture of the style of 1990s handheld and home consoles such as the GameBoy Color and NES. The project was not supposed to imitate a specific game or style, but rather to remind of this era.
I decided that one game block would consist of 16×16 pixel tiles. The screen displays a 10×10 grid, giving a resolution of 160×160 pixels (fully scaled to make the game look beautiful on modern displays). The result is something similar to a GameBoy screen, but it uses 8×8 pixel tiles and has a resolution of 160×144 pixels.
Due to game size limitations, I didn’t start using spritemaps. Instead, I used a reduced size emoji to compose the sprites.
The picture above shows how the player model was assembled. It uses four emojis. For the head I took the head emoji and put the glasses on it, the bottom part is the pants sprite. However, I made the body from
red envelope
– A monetary gift common in Southeast Asia. When drawing in this resolution, it loses all the details that the elongated shape perfectly imitates the body.
Emojis look different in different operating systems, which is why the game is rendered differently in each of them. The differences can be seen in the image below:
Moreover, not all systems support the same version of Unicode, so some new emojis are incompatible with older systems. In the screenshot above, you can see that in Windows and Ubuntu, the tombstone emoji has been replaced with a coffin (⚰️) because
gravestone was introduced in Unicode 13.0
. To achieve this I created a small script that renders all the emojis and checked that they render correctly. If not, it was replaced by the equivalent specified in the manually created configuration. Since emojis may have different sizes on different systems, manual adjustment is required.
interface OptionalEmojiSet {
// EMOJI
e?: string;
// POS
pos?: [number, number];
// SIZE
size?: number;
}
export const alt: Record<string, OptionalEmojiSet> = {
"🪦": { e: "⚰️", pos: [0, 4], size: .9},
"⛓": { e: "👖" },
"🪨": { e: "💀" },
"🪵": { e: "🌳" },
"🦴": { e: "💀" }
}
export const win: Record<string, OptionalEmojiSet> = {
"🔥": { pos: [1, -1], size: 1 },
"💣": { pos: [-1, -2]},
"👱": { pos: [-1, 0]},
"🕶": { size: 1.5, pos: [-1, -1]},
"⬛️": { pos: [-1, 0]},
"👖": { pos: [-0.5, 0]},
"🐷": { pos: [-1, 0]},
"🦋": { pos: [-1, 0]},
"🐮": { pos: [-1, 0]},
"👔": { pos: [-1, 0]},
"👩": { pos: [-1, 0]},
"🤖": { pos: [-1, 0]},
"👚": { pos: [-1, 0]},
"🐵": { pos: [-1, 0]},
"☢️": { pos: [5, 0]},
"👹": { pos: [-2, 1]}
}
export const tux: Record<string, OptionalEmojiSet> = {
"🧧": { e: "👔" }
}
This table
has proven to be a very useful resource for checking when a specific emoji was introduced.
Structure of the game screen
The screen displays a 10×10 grid of 16×16 pixel tiles, giving a resolution of 160×160 pixels. It is obvious that on modern 4k screens, when displaying the game in this resolution, a very small square would appear, so the scale of the image was increased by a factor of as much as possible.
To scale down the emoji, I used a separate small canvas on which each of the sprites renders at a native resolution of 16×16 pixels. These sprites could then be rendered on the game’s large canvas. This allowed me to generate temporary sprite sheets to check that the tiles were being generated correctly.
By default, canvas used image smoothing, that is, as the sprite scaled, it was smoothed using anti-aliasing. I had to turn it off to avoid this.
context.imageSmoothingEnabled = false;
▍ Canvas optimization
One of the weird optimization techniques I came up with was to generate a bitmap from the canvas without saving it. I suspect that this process marks part of the canvas as unchanged for later use, which can be used to optimize the rendering, but I haven’t been able to find a concrete answer about the mechanism that causes this.
createImageBitmap(ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height)) // Это ускоряет последующий рендеринг
QuadTree
During the game, many game objects appear and disappear on the screen. The main part of the game logic consists in determining which objects are close to each other and in determining collisions or distances to them – this applies to all interactions such as movement, collision detection, chasing the player by enemies, and so on; includes the logic of searching for objects within a certain distance. The most naive solution to this task would be to check all objects stored in memory. Unfortunately, this requires many comparisons. If you want to perform this operation for each of the elements, then in each frame you will have to do O(n
2
) Compare.
Thanks to QuadTree, we can perform pre-computations to optimize this process. The tree stores our elements in its nodes until the node reaches its limit size. In this case, it divides the space into four subspaces. Then, when we want to find all elements in a given area, we can simply discard the objects outside the boundary. The whole process is recursive, so we get a structure in which it is very easy to find elements.
The recursive implementation itself is not very long and easily fits into a hundred lines.
import { GameObject } from "../modules/GameObjects/GameObject";
import { Rectangle } from "../modules/Primitives";
export class QuadTree {
objects: Set<GameObject> = new Set();
subtrees: QuadTree[] = [];
constructor(private _boundary: Rectangle, private limit: number = 10) {
}
get boundary() {
return this._boundary;
}
get subTrees() {
return this.subtrees;
}
private subdivide() {
const p1 = this.boundary.p1;
const mid = this.boundary.center;
const w = this.boundary.width;
const h = this.boundary.height;
this.subtrees = [
new QuadTree(new Rectangle(p1, mid), this.limit),
new QuadTree(new Rectangle(p1.add(w/2, 0), mid.add(w/2, 0)), this.limit),
new QuadTree(new Rectangle(p1.add(0, h/2), mid.add(0, h/2)), this.limit),
new QuadTree(new Rectangle(p1.add(w/2, h/2), mid.add(w/2, h/2)), this.limit),
];
// теперь мы должны добавить все имеющиеся точки
this.objects.forEach(o => {
this.subtrees.forEach(t =>
t.add(o)
)
})
}
add(obj: GameObject) {
if (!this.boundary.isIntersectingRectangle(obj.getBoundingBox())) {
return;
}
if (this.objects.size + 1 < this.limit || this.boundary.width < 10 || this.boundary.height < 10) {
this.objects.add(obj);
} else {
if (!this.subtrees.length) {
this.subdivide();
}
this.subtrees.forEach(t => {
t.add(obj);
});
}
}
getInArea(boundary: Rectangle): Set<GameObject> {
if (!this.boundary.isIntersectingRectangle(boundary)) {
return new Set();
}
if (this.subtrees.length) {
const s = new Set<GameObject>();
for (const tree of this.subTrees) {
tree.getInArea(boundary).forEach(obj => s.add(obj));
}
return s;
}
const points = new Set<GameObject>();
this.objects.forEach(obj => {
if (boundary.isIntersectingRectangle(obj.getBoundingBox())) {
points.add(obj);
}
});
return points;
}
}
Dithering effect
To create retro aesthetics, instead of changing the opacity of the shadows, I used dithering.
The dithering effect is a technique of simulating a wider palette of colors by alternating the use of colors from a smaller set. In the picture above, each of the patterns seems to increase in brightness, despite the fact that they only use two colors – black and purple.
This effect was often used in older console and PC games where the palette was limited. This forced the developers to go for tricks and apply techniques like dithering to trick our eyes into thinking that there are more colors than what is actually visible on the screen. The most famous example of the use of dithering was the GameBoy Camera, in which ordered dithering was used to display captured images.
I plan to write a separate article about this effect and how to use it, but in the meantime you can view its code on CodePen:
Dither
Post-processing
Post-processing was an important stage, it gave the game a realistic retro look. When displaying color images, old CRT displays created unique artifacts due to their architecture.
To simulate a retro style, I applied a variety to each game pixel
Bayer filter
with a slight blur, then blended them using the color-burn blending mode. It added an original style to the game. Unfortunately, it became much darker, but since the main theme is related to death, it seemed appropriate to me.
Here is the code snippet responsible for this effect:
// отрисовываем canvas постэффекта, если это не было сделано ранее, с небольшим размытием между цветными "пикселями"
if (!this.postCanvas) {
this.postCanvas = document.createElement('canvas');
const m = this.game.MULTIPLIER;
this.postCanvas.width = this.postCanvas.height = m;
const ctx = this.postCanvas.getContext('2d')!;
ctx.filter="blur(1px)";
ctx.fillStyle = "red";
ctx.fillRect(m/2, 0, m / 2, m/2);
ctx.fillStyle = "green";
ctx.fillRect(0, 0, m/2, m);
ctx.fillStyle = "blue";
ctx.fillRect(m/2, m/2, m/2, m/2);
this.pattern = ctx.createPattern(this.postCanvas, "repeat")!;
}
// выполняем смешивание в режиме color-burn
this.ctx.globalAlpha = 0.6;
this.ctx.globalCompositeOperation = "color-burn";
this.ctx.fillStyle = this.pattern;
this.ctx.fillRect(0, 0, this.width, this.height);
// откатываемся к исходному режиму смешения.
this.ctx.globalCompositeOperation = "source-over";
this.ctx.globalAlpha = 1;
audio
To create the music, I used the WebAudio API, which has a node-based architecture that can be used to combine frequency generators, filters, effects, and other audio nodes.
I
implemented a simple audio tracker
, which reads pre-recorded scores and assigns audio. A track can be written as a string of MIDI notes encoded as UTF-8 characters:
The composition consists of several tracks. They have the same BPM, but can have different time distributions, making it easier to iterate on musical ideas.
If you want to learn more about using Web Audio, I recommend my article on this topic: Playing with MIDI in JavaScript.
And if you want to learn more about MIDI and how to use it in the browser, I recommend an introductory article about my MIDI library written in TypeScript: Introducing MIDIVal:
Packet size optimization
After finishing the game, it’s time to compress. First of all, I tried to limit myself when writing the code to use OVP with real class names, and to postpone the minification to a separate stage. This kept the bulk of the code readable (I only had to make a few edits) but still minified nicely.
The limit is 13,312 bytes (13 * 1024), and my uncompressed packet was originally about 14,500 bytes. That is, I needed to cut it by 1.5 KB.
I started using uglify in the build thread, which removed some console.log and shortened some names. I experimented a bit with parameters, but they didn’t do a very good job of replacing my class and method names. I decided to use my own solution.
Code modifier for TypeScript
Fortunately, the entire code base was written in TypeScript. With this I was able to easily rename most class, property and function names using a simple code modifier written with ts-morph.
TS-morph is a simple tool to help navigate the TypeScript AST tree. Instead of manually navigating the nodes, you can easily extract all symbols from the file and automatically change them in all places.
let name = [65, 65];
let generateNextName = () => {
if (name[1] === 90) {
name[0]++;
name[1] = 65;
}
name[1]++;
return nameString();
}
classes.forEach(i => {
const name = i.getName();
const nextName = generateNextName();
if (name === nextName) {
return;
}
// Переименование класса
console.log(name, '->', nextName);
i.rename(nextName, {
renameInComments: false,
renameInStrings: false
});
});
The above code changes all class names to
AA
,
AB
,
AC
and so on. Since we’re using a typed language, we know where the class is used, so all uses of the class are automatically renamed as well. The same happens with class methods and properties.
Here is my complete code modifier
.
▍ Roadroller
To further reduce the size, I used
road roller
. It’s a tool that compresses JavaScript using sophisticated compression techniques like the rANS byte-by-byte encoder, making the source code completely unreadable but very efficient. It cut a few kilobytes of my code and it fit within the constraints without any problems.
Telegram channel with prize draws, IT news and posts about retro games 🕹️