heckx2

Sprite font for a canvas game: Part 1

May 08, 2020

Almost every game uses text to communicate information to a player. Menus, dialogs, names of characters, items and locations to name a few.

HTML5 Canvas has a powerfull API for rendering text in a form of fillText method. There are lots of fonts on the Internet, including pixel fonts re-created from old games, which you can simply download and use with fillText. But if the font does not have all the characters that you need for the game, it might become an issue.

If you are familiar with a software for creating fonts, you can use it to create your own font and edit it any time you need a new character. This way you can still take advantage of fillText.

But for a person like me who did not want to spend much time on figuring out how to create fonts and is more comfortable in writing something from scratch to have more control, there is a solution - create your own version of fillText, which might sound crazy at first, but hear me out. All the characters of the font will be placed in a PNG image, which gives flexibility of being able to add new characters any time you want by just editing an image. The downside is that the entire functionality of fillText has to be re-created, starting from loading the image, splitting it into characters and then drawing them in specific positions on a canvas. If your game needs different font sizes and colors, then it has to be implemented by yourself as well. But don’t worry, it is not actually as difficult as it sounds and I am going to walk you through all of it step by step.

The overall idea is to split a text you want to render into characters, then find each character on a sprite image and render them one by one on a canvas using drawImage.

Idea

I must warn that rendering each character individually might affect the perfomance if you have a lot of text. If you want to follow this approach you should investigate how it works with your game.

Sprite

In my game I only use english alphabet and all the text is upper-cased, so I only need one upper-cased version of letters. I also need numbers from 0 to 9 and a bunch of special characters. Here is the full list of characters:

ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-ⅠⅡ←→[]?()/+

Now let’s prepare an image for them. Not to make it too complicated we are going to create a monospace font which means that each character has the same width. Character’s width and height will be 7px and extra 1px is reserved as a spacing between the characters, so that they don’t mix with each other and it will be easier to pick them apart when you are looking at them. All the characters are placed in a table with 10 characters in a row, but it is totally up to you how many rows or columns there are.

ABCDEFGHIJ
KLMNOPQRST
UVWXYZ1234
567890-ⅠⅡ←
→[]?()/+

Knowing the size of a single character and that a table has 10 columns and 5 rows, size of the image can be calculated precisely, which is 80x40.

imageWidth = (characterWidth + horizontalSpacing) * columnCount
imageHeight = (characterHeight + verticalSpacing) * rowCount

In your preferred graphics editor create a new file of the calculated size. I recommend using guides to outline each cell of the table and a grid to highlight each pixel of the character. Here is an example of the file I’ve created in GIMP:

Grid

After that we simply draw each character pixel by pixel using one color. If your game only needs a single text color, then it should be the color you need. Otherwise, pick one of the colors you use the most and use it. Having it in one color will help later on when we are going to create multiple versions of different colors.

Sprite

It is important that a background of the image is transparent and the image is saved in PNG format, otherwise the background will be rendered together with the characters.

Config

To describe how the characters are positioned on the image, we should define a configuration object. All of the properties have already been mentioned above, we just gather them together.

const config = {
  characterSet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-ⅠⅡ←→[]?()/+',
  characterWidth: 7,
  characterHeight: 7,
  horizontalSpacing: 1,
  verticalSpacing: 1,
  columnCount: 10,
  rowCount: 5,
};

If you change the image by adding a new character or modify number of columns or size of the characters, you should update the config as well. Characters in config.characterSet must be in the same order as they are on the image.

Finding the character

To extract the character from the source image, we need to know it’s X and Y coordinates.

Let’s pick character “M” as an example. By looking at the image it is pretty easy for us to figure out that the character “M” is in the 2nd row and in the 3rd column of the table (or if we count from zero - 1st row and 2nd column).

Character M

Characters defined in config.characterSet are stored in a single row, “M” is at position 12 in the character set. We know that there are 10 characters in a table row, so we can also say that “M” is at position “10 + 2 (= 12)”, which is 1 full row plus 2 columns.

const character = 'M';
const characterIndex = config.characterSet.indexOf(character);

const rowIndex = Math.floor(characterIndex / config.columnCount);
const columnIndex = characterIndex % config.columnCount;

// rowIndex = 1
// columnIndex = 2

X and Y can be calculated by multiplying table indicies by character size + spacing.

const {
  characterWidth,
  characterHeight,
  horizontalSpacing,
  verticalSpacing,
} = config;

const sourceX = columnIndex * (characterWidth + horizontalSpacing);
const sourceY = rowIndex * (characterHeight + verticalSpacing);
const sourceWidth = characterWidth;
const sourceHeight = characterHeight;

// sourceX = 16
// sourceY = 8
// sourceWidth = 7
// sourceHeight = 7

If you count the pixels leading to the “M” character on the image above, you should get the same X and Y coordinates.

Drawing a character

To start drawing we need to create a canvas and add it to a document:

const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 200;
canvas.style.border = '1px solid gray';
document.body.appendChild(canvas);

const context = canvas.getContext('2d');

Canvas

Then we need to load a sprite image. Once image is loaded, we are ready to draw a character:

const image = new Image();
image.addEventListener('load', () => {
  drawCharacter('M');
});
image.src = 'https://i.imgur.com/sdld5KH.png';

Sprite

At this point we have an image source and we have a canvas to draw onto. All what is left is to get a character and draw it on the canvas. To get character location on the image we are mostly using the code already explained before. To draw we use HTML5 Canvas drawImage method and provide it with the image, character coordinates and size, and then destination coordinates where to draw it on canvas and what size it should be.

function drawCharacter(character) {
  const characterIndex = config.characterSet.indexOf(character);

  if (characterIndex === -1) {
    throw new Error(`Font character "${character}" is not defined`);
  }

  const rowIndex = Math.floor(characterIndex / config.columnCount);
  const columnIndex = characterIndex % config.columnCount;

  const {
    characterWidth,
    characterHeight,
    horizontalSpacing,
    verticalSpacing,
  } = config;

  const sourceX = columnIndex * (characterWidth + horizontalSpacing);
  const sourceY = rowIndex * (characterHeight + verticalSpacing);
  const sourceWidth = characterWidth;
  const sourceHeight = characterHeight;

  const destinationX = 0;
  const destinationY = 0;
  const destinationWidth = characterWidth;
  const destinationHeight = characterHeight;

  context.drawImage(
    image,
    sourceX,
    sourceY,
    sourceWidth,
    sourceHeight,
    destinationX,
    destinationY,
    destinationWidth,
    destinationHeight,
  );
}

You should see a very small “M” (7x7px) rendered in the top left corner of the canvas.

Render character

Positioning a character

Rendered character position is directly controlled by destinationX and destinationY variables. Note that canvas coordinate system starts at the top-left corner. Let’s make position a parameter to the function.

function drawCharacter(character, position = { x: 0, y: 0 }) {
  // ...

  const destinationX = position.x;
  const destinationY = position.y;

  // ...
}

If we now call

drawCharacter('M', { x: 100, y: 20 });

we should see the character reposition on the canvas.

Position character

Scaling a character

Scaling tells how much the size of the rendered character will change and can be achieved by multiplying destination size by a scale factor. Function drawCharacter will now receive a scale parameter as well.

function drawCharacter(character, position = { x: 0, y: 0 }, scale = 1) {
  // ...

  const destinationWidth = characterWidth * scale;
  const destinationHeight = characterHeight * scale;

  // ...
}

Now we can draw our character five times larger by calling:

drawCharacter('M', { x: 100, y: 20 }, 5);

But if you look at the result, the character appears very fuzzy.

Scale character smooth

That’s because our source image is rather small and canvas smoothes it out when the character is drawn in a larger size. There at least two solutions for this:

  1. Set imageSmoothingEnabled to false which will tell the canvas not to smooth out the image when scaled.
  2. Change source image to have characters of a larger size. It will work nicely if you only have one font size throughout your entire app.

We will go with the first approach:

context.imageSmoothingEnabled = false;

After disabling image smoothing the character looks sharp.

Scale character sharp

By adding positioning and scaling we can now render a character of any size anywhere on the canvas.

Live demo with character positioning and scaling can be found here: Character demo

Drawing a word

Word is basically just a list of characters drawn one after another in a line with some space in between. To draw a word we split it into characters and draw each character individually by changing it’s position relative to the word.

Word explanation

We will start simple and then add positioning and scaling. We walk over each character, calculate it’s position and use already implemented drawCharacter function to render the character. Note that the very first character in a word should not have a space in front. Parameter optCharacterSpacing will allow to configure the distance between the characters.

function drawWord(word, optCharacterSpacing = 1) {
  const characters = Array.from(word);

  characters.forEach((character, characterIndex) => {
    let characterSpacing = optCharacterSpacing;
    if (characterIndex === 0) {
      characterSpacing = 0;
    }

    const characterTotalWidth = config.characterWidth + characterSpacing;

    const characterX = characterIndex * characterTotalWidth;

    const characterPosition = {
      x: characterX,
      y: 0,
    };

    drawCharacter(character, characterPosition);
  });
}

When calling

drawWord('DOG');

the word is rendered in the top-left corner of the canvas with 1 pixel in between the characters.

Render word

Positioning a word

Changing word position means changing the position of all the characters in that word, each character will be translated in respect to the word’s position. We add a new position parameter as we did with the character.

function drawWord(word, position = { x: 0, y: 0 }, optCharacterSpacing = 1) {
  // ...

  const characterX = position.x + characterIndex * characterTotalWidth;
  const characterY = position.y;

  const characterPosition = {
    x: characterX,
    y: characterY,
  };

  // ...
}

To change word position call

drawWord('DOG', { x: 100, y: 20 });

and you will see that all the characters are moved together.

Word position

Scaling a word

Scaling as well as positioning affects each character. As they become larger they start taking more space and should be positioned farther from each other. When we calculate character position inside a word, we should now take into account the scale of the character. New parameter scale is added to the function, it is also passed to drawCharacter.

function drawWord(
  word,
  position = { x: 0, y: 0 },
  scale = 1,
  optCharacterSpacing = 1,
) {
  // ...

  const characterTotalWidth = config.characterWidth * scale + characterSpacing;

  // ...

  drawCharacter(character, characterPosition, scale);

  // ...
}

After calling the following function the entire word is now 5 times larger:

drawWord('DOG', { x: 100, y: 20 }, 5);

Word scaled

Live demo with word positioning and scaling can be found here: Word demo

Line

Line of words follows the same logic as a word of characters. Line is split into words which are split into characters.

Line explanation

Words in a sentence are usually separated by a space and it will be our indicator of how to split the line.

First we need a helper function to calculate a word width, because each word has different length and we need to accumulate the offset as we go.

function getWordWidth(word, scale = 1, optCharacterSpacing = 1) {
  const allCharactersWidth = word.length * (config.characterWidth * scale);
  const allSpacingsWidth = (word.length - 1) * optCharacterSpacing;

  const wordWidth = allCharactersWidth + allSpacingsWidth;

  return wordWidth;
}

This time we are going to implement positioning and scaling right away, that’s why we are already using position and scale parameters. The space between words will have the size of a single character and will be added after each word except the last one.

function drawLine(
  line,
  position = { x: 0, y: 0 },
  scale = 1,
  optCharacterSpacing = 1,
) {
  const words = line.split(' ');

  let prevWordEndX = 0;

  words.forEach((word, wordIndex) => {
    const wordX = position.x + prevWordEndX;
    const wordY = position.y;
    const wordPosition = {
      x: wordX,
      y: wordY,
    };

    drawWord(word, wordPosition, scale, optCharacterSpacing);

    const wordWidth = getWordWidth(word, scale, optCharacterSpacing);

    let wordSpacing = config.characterWidth * scale + optCharacterSpacing * 2;
    if (wordIndex === words.length - 1) {
      wordSpacing = 0;
    }

    const wordTotalWidth = wordWidth + wordSpacing;

    prevWordEndX += wordTotalWidth;
  });
}

Now we are ready to draw the line of words using custom position and scale factor.

drawLine('THE DOG BARKS', { x: 10, y: 20 }, 3);

Line

Live demo with line positioning and scaling can be found here: Line demo

Text

Text is a combination of vertically stacked lines and will cover most popular cases of text rendering, it combines all of the functions described above.

Text explanation

We will use special character \n to separate lines when submitting text to the function. The code is very much similar to drawing a line of words. Positioning and scaling will be implemented right away and there is also a new paramter optLineSpacing for configuring the distance between the lines.

function drawText(
  text,
  position = { x: 0, y: 0 },
  scale = 1,
  optLineSpacing = 10,
  optCharacterSpacing = 1,
) {
  const lines = text.split('\n');

  lines.forEach((line, lineIndex) => {
    let lineSpacing = optLineSpacing;
    if (lineIndex === 0) {
      lineSpacing = 0;
    }

    const lineTotalHeight = config.characterHeight * scale + lineSpacing;

    const lineX = position.x;
    const lineY = position.y + lineTotalHeight * lineIndex;

    const linePosition = {
      x: lineX,
      y: lineY,
    };

    drawLine(line, linePosition, scale, optCharacterSpacing);
  });
}

By providing a multiline text to drawText we get the result.

drawText('THE DOG BARKS\nVERY LOUDLY\nAT THE FENCE', { x: 10, y: 20 }, 3);

Text

Text alignment

To get extra fancy we will add horizontal text alignment. Text is left-aligned by default, the other options are “right” and “center”.

To align the text by the right side, we need to know where is the right boundary of the text. It can be easily calculated by adding text X position with the text width, and text width is basically equal to the width of the longest line.

Text width

First we need a helper function to calculate the width of the line. It splits the line into words and sums the lengths of all the words including the spacing between them.

function getLineWidth(line, scale = 1, optCharacterSpacing = 1) {
  let lineWidth = 0;

  const words = line.split(' ');
  words.forEach((word, wordIndex) => {
    const wordWidth = getWordWidth(word, scale, optCharacterSpacing);

    let wordSpacing = config.characterWidth * scale + optCharacterSpacing * 2;
    if (wordIndex === 0) {
      wordSpacing = 0;
    }

    const worldTotalWidth = wordWidth + wordSpacing;

    lineWidth += worldTotalWidth;
  });

  return lineWidth;
}

The text width is then calculated by splitting the text into lines, getting the width of each line and finding the one with the maximum width:

function getTextWidth(text, scale = 1, optCharacterSpacing = 1) {
  const lines = text.split('\n');

  const lineWidths = lines.map((line) => {
    return getLineWidth(line, scale, optCharacterSpacing);
  });

  const maxLineWidth = Math.max(...lineWidths);

  return maxLineWidth;
}

Once the text width is known it means that the right boundary of the text is known as well. To align the lines by the right side each line should be moved by the difference between the text width and the line width.

Text align right explanation

Centering the text requires to move each line by half of the difference between the text width and the line width.

Text align center explanation

Finally we can update the drawText function and add a new parameter align for setting horizontal text alignment.

function drawText(
  text,
  position = { x: 0, y: 0 },
  scale = 1,
  align = 'left',
  optLineSpacing = 10,
  optCharacterSpacing = 1,
) {
  const textWidth = getTextWidth(text, scale, optCharacterSpacing);

  const lines = text.split('\n');

  lines.forEach((line, lineIndex) => {
    let lineSpacing = optLineSpacing;
    if (lineIndex === 0) {
      lineSpacing = 0;
    }

    const lineWidth = getLineWidth(line, scale, optCharacterSpacing);

    let lineX = position.x;
    if (align === 'center') {
      lineX += (textWidth - lineWidth) / 2;
    } else if (align === 'right') {
      lineX += textWidth - lineWidth;
    }

    const lineTotalHeight = config.characterHeight * scale + lineSpacing;

    const lineY = position.y + lineTotalHeight * lineIndex;

    const linePosition = {
      x: lineX,
      y: lineY,
    };

    drawLine(line, linePosition, scale, optCharacterSpacing);
  });
}

By calling drawText with different values of align we get different kinds of text.

const text = 'THE DOG BARKS\nVERY LOUDLY\nAT THE FENCE';

drawText(text, { x: 10, y: 20 }, 3, 'center');
drawText(text, { x: 10, y: 20 }, 3, 'right');

Text center Text right

Live demo with text positioning, scaling and alignment can be found here: Text demo

Summary

Those are just the basic examples of rendering the static text. Having this kind of flexibility opens up a lot of possibilities of controlling how the text is rendered. But it comes with the price of possibly decreased performance and having to write everything from scratch.

In the next post I am going to explain how to make the font of different colors. As a preparation for the next post and also for having an alternative way of organizing the code, I’ve combined all of the functions described above into a single class, which you can use in your projects for drawing sprite-based text.

Class-based demo can be found here: Class demo

Next post: Sprite font for a canvas game: Part 2 - Adding color

Check out the game where I used this technique for text rendering - it’s a clone of a game called Battle City (1985) by Namco written from scratch in TypeScript:

https://dogballs.github.io/cattle-bity/

Source code is open and available on GitHub:

https://github.com/dogballs/cattle-bity


Personal blog by Michael Radionov
Software development stories