As is usual this time of the year, I participated in the Js13Games game jam where we have 1 month to create a game that fits under 13KB.
I didn't have a lot of time this year for a variety of reasons, but I tried my best to complete something. This year's theme was "XIII Century". I didn't want to do the obvious and focus on wars and knights, both because that seemed too obvious and also because creating games about violence is just not my jam (pun intended). So I thought I would do something about merchants and trading.
This year I wanted to challenge myself to learn more about WebGL. And because images are often the densest part of these games (each tiny image can easily gobble up a couple precious KBs!), I wanted to create a game with images that were fully generated by code. This post talks about my experience with making pixel art in the web canvas.
Concept art
I idealized a stand in a market where the player would be selling the goods they purchased before. The play could choose the price and arrangement of items. Barrels could hold liquids like beer or conserves like olives, while the boxes could present fruits, bread and other objects.
I called the game Market Street Tycoon because I dreamed that the game would have progressing levels where the player would control all the shops in the market. Alas that was a bit too much for my free time during the month-long competition.
I started with drawing some concept art. GIMP is a pretty alright pixel art editor. I decided to create a game with the same resolution as the Gameboy Color, 160 x 144. This decision has several implications. On one hand it leaves little space to write text and add controls, and anything drawn will have very little detail. On the other hand, pixel art this small is great to mask your bad drawing skills. It's also much easier to develop a game with a fixed resolution.
Pixel art in the canvas
The web canvas is notoriously inhospitable for pixel art. Unless one takes care to align everything very neatly to the pixel grid, you can expect very blurry lines. While there are options to disable anti-aliasing of images, the same isn't possible for drawing shapes or text.
Using the native 2D rendering functions won't be enough. Yes, it's possible do draw sharp rectangles, but ellipses and lines won't render sharply when zoomed in. Even when drawing rectangles, there are some quirks in the way certain browsers do it.
Rectangles and borders
In the example above, I'm rendering a 20px x 20px square with a black 1px stroke. Not only does the stroke render 2px wide, but it is also semi-transparent. This is because, although the square is aligned to the grid, the stroke is centered in the edge of shape, so the stroke goes half pixel to each side. Standard DPI computers can't deal with half-pixels, so that's what we get. Also, you might have noticed that it looks different in Firefox and Chrome. I had this problem initially when drawing the bricks in the background of the game.
My quick solution was to simply draw 2 squares, one rendering inside the other.
x = 20, y = 20, width = 20, height = 20
ctx.fillStyle = 'black';
ctx.fillRect(x, y, width, height);
ctx.fillStyle = 'white';
ctx.fillRect(x + 1, y + 1, width - 2, height - 2);
The sharp eye might have noticed the obvious limitation of this approach: it draws opaque rectangles. This means it's not possible to draw transparent rectangles on top of other things. But for my use case of drawing bricks in the background, that's perfectly acceptable. In fact, I only need to draw the "stroke" as a single screen-sized dark backdrop to speed things up.
Placing pixels on the canvas
There are two common ways to draw individual pixels. The first one is to form an ImageData
object with the desired dimension and fill in its data
.
ctx.fillStyle = "yellow"
ctx.fillRect(20, 20, 20, 20)
// copies a section of the canvas into imagedata
let imageData = ctx.getImageData(20, 20, 20, 20)
let i = 0
while (i < 20) {
imageData.data[4 * 21 * i] = 0 // red channel
imageData.data[4 * 21 * i + 1] = 0 // green
imageData.data[4 * 21 * i + 2] = 0 // blue
imageData.data[4 * 21 * i + 3] = 255 // alpha
i++;
}
// places the image data back into the canvas
ctx.putImageData(imageData, 20, 20);
The reason why we need to use getImageData
and then putImageData
is because, even though most pixels of the image data are transparent (alpha channel = 0), when we put the data back in the canvas, it replaces whatever was in that position.
It's also confusing what we're doing in the while
in the example above. imagedata.data
is a one-dimensional array, but our image is two-dimensional, so we need to map x
and y
to the index of the array.
The second method doesn't require any mapping, and can draw "pixels" directly on the canvas. Essentially, the second method draws a tiny rectangle for each pixel. The following snippet renders the same yellow square from before.
ctx.fillStyle = "yellow"
ctx.fillRect(20, 20, 20, 20)
ctx.fillStyle = "black"
posx = posy = 20
for (let i = 0; i < 20; i++) {
ctx.rect(posx + i, posy + i, 1, 1)
}
ctx.fill()
I'm pretty sure this is terribly inefficient, but it is also really intuitive because it uses the same functions (i.e. rect
) used to draw the bricks, the menus, the buttons etc. For that reason, I mostly used this approach for drawing pixel art.
Drawing pixelated lines
I didn't want to reinvent the wheel here. Surely someone has created a line-drawing algorithm. I looked around for algorithms and did find different ones with varying complexity. After spending sometime trying to make them work for my use case, I sat down and decided that this has to be simple enough... So I reinvented the wheel.
drawLine(x0: number, y0: number, x1: number, y1: number, color: string) {
// It's necessary to start from rounded coordinated
let x = Math.round(x0);
let y = Math.round(y0);
x1 = Math.round(x1);
y1 = Math.round(y1);
// Calculate the deltas
let dx = x1 - x0;
let dy = y1 - y0;
let dmax = Math.max(Math.abs(dx), Math.abs(dy));
// Either dx or dy will equal 1
// and the other will be less than 1
dx = dx / dmax;
dy = dy / dmax;
const points = [ ];
this.context.fillStyle = color;
while(dmax--) {
// The points won't have whole coordinated;
// We'll round them later.
points.push([x, y]);
x += dx;
y += dy;
}
this.context.beginPath();
points.map(([px, py]) => {
// We round the coordinates when drawing the pixel.
this.context.rect(Math.round(px), Math.round(py), 1, 1);
});
this.context.fill();
}
And to draw the clock, I use cos
and sin
to draw lines that start in the center of the clock and go around in a circular motion.
this.context.beginPath();
this.context.fillStyle = WHITE2;
this.drawCircle(WIDTH / 2, 8, 7);
this.context.fill();
// t between 1 and 0
const angle = 2 * Math.PI * t - Math.PI/2;
const xc = WIDTH / 2;
const yc = 8;
const r = 6;
this.drawLine(xc, yc, xc + r * Math.cos(angle), yc + r * Math.sin(angle), BLACK);
Drawing pixel art icons
Despite the click-bate-y title of this article, I did use "images" in the game to draw the little icons for the apples and etc. But instead of image files, they are represented by a compressed string that codifies the position of the pixels and their color in a palette of 8 colors.
Here I've used xem's mini pixel art editor.
Xem's code is strongly minified, so I had to rewrite it slightly to understand what was going on. I minimize all my code later, so I like to always keep my source code tidy.
// Adapted from https://xem.github.io/miniPixelArt/
// An icon looks like this: '@X@@C@RSERRBWRGx@'
drawIcon(icon: string, x: number, y: number) {
const imageData: number[] = [];
[...icon.data].map(c => {e
// Decodes each character
const z = c.charCodeAt(0);
imageData.push(z&7);
imageData.push((z>>3)&7);
});
// Draw the pixels
for (let j = 0; j < icon.size; j++) {
for (let i = 0; i < icon.size; i++) {
if (imageData[j * icon.size + i]) {
const index = 3 * (imageData[j * icon.size + i]-1);
this.context.fillStyle = '#' + PALETTE.substring(index, index + 3);
this.context.fillRect(x + i, y + j, 1, 1);
}
}
}
}
Drawing text
For this i simply used xem's mini pixel font. This is pretty ingenious. First the text is rendered in a separate canvas in black and white, but anti-aliased. Then the pixels are read from it and converted to black or white depending on a threshold of brightness. Finally, the text is drawn on the game pixel-by-pixel.
The advantages of this method is that I don't need to include a pixel font in my game - I'm using your system's fonts! I also can leverage using unicode to provide symbols for my game.
The downside is that is doesn't look great!
Some letters can be hard to read and their shape isn't consistent. Finally, drawing all these pixels is, again, terribly inefficient.
Final notes
It was a lot of fun to make pixel art with the canvas. I look back at my older games and this one looks so much better. I'm particularly proud of the slick curtains animation.
This article is by do means a tutorial or an explanation of bad practices. This project was built under strong constraints (game size limit and 1 month deadline) and these are simply the solutions I found for my problems.
If you're still curious about the code of the game, you can check the source out on my github: https://github.com/lopis/market-street-tycoon. If you want to play the game, you can do so here: https://js13kgames.com/entries/market-street-tycoon
Update
In the time since publishing this article, I went back and redid the way I draw fonts in an easy and cheap (in kb) way. It's not without drawbacks. I hope to write a bit about it too. If there's interested, let me know in the comments!
Top comments (0)