Chess Diagram Generator in NodeJS

A while ago a wrote an article about generating simple chess diagrams in PHP. Today, we’ll achieve the same in node. With some small changes, the code can also work client-side, since it uses canvas.

How it’ll look

This is how the diagram will look:

sample chess diagram

Where it’s used

I use the code as part of a much larger API for SparkChess, my company’s online chess game. Users can take “snapshots” of the chessboard and download the PNG. In SparkChess, we use two kind of boards: the more advanced WebGL board, a full 3D, interactive board, and a simpler CSS-based board for resource conservation. We can take a snapshot of the WebGL board client-side by converting the canvas to blob, but we can’t do the same with HTML+CSS, hence the need for a server-side solution.

Prerequisites

You’ll need NodeJS running locally and some knowledge of how to install packages with npm.

Dependencies

Personally, I like to keep the number of dependencies to a minimum. For this project we’ll use express (because its the de-facto standard and everyone uses it, but we could do without it) and a canvas package. I use @napi-rs/canvas because for me it seems to work better, but ultimately it’s a matter of preference.

Handling the request

The main part of the app is quite simple:

import express         from 'express';
import {fileURLToPath} from 'url';
import path            from 'path';
import {createCanvas, GlobalFonts} from '@napi-rs/canvas';

const app = express();
app.listen(3000);
app.use('/diagram', (req, res)=>{

    const fen    = req.query.fen,
          rev    = req.query.rev    == '1',
          inline = req.query.inline == '1',
          sz     = req.query.size ?? 800;
          
    if (!fen)
        return res.status(400).send('Invalid FEN');

    const size = parseInt(sz);
    if (isNaN(size) || (size < 100) || (size > 2000))
        return res.status(400).send('Size should be a positive integer');

	const buffer = draw(fen, rev, size);
 
    res.writeHead(200, {
        'Content-Type': 'image/png',
        'Content-Length': buffer.length,
        'Content-disposition': inline ? 'inline' : `attachment; filename="${Date.now()}.png"`
    });
    res.end(buffer);
});

We initialize the web server and make it listen on port 3000. We specify a route for /diagram and its corresponding callback.

As you can see, the path accepts the following query parameters:

  • fen – the board representation (explained a bit later).
  • rev – if specified, request to draw the board reversed, that is the first rank at the top will be 1 and the bottom rank will be 8. Normally the board is drawn from the white’s perspective, with the 8th rank at the top.
  • size – the board size in pixels. The board is square, so the size is both width and height.
  • inline – if specified, display the image. By default, a download is forced.

We read the query parameters, do a bit of validation, call the draw function (which doesn’t exist yet) and output the result with the needed headers.

Preparing the canvas

To draw the board, we’ll use a chess font in addition to a regular font. There are plenty of free chess fonts on the web, so choose whatever you like. I placed them a /public folder in the app’s root.

And here we hit a small snag: I prefer to use ES modules. But getting the project path is not that simple – we can’t use __dirname with modules. This is not really an issue in this small project, but if you use Typescript and /src & /dist folders or something more complex, you may have trouble getting the public folder. In that case, you may need a small utility function to get the file’s folder. I’m including it just in case:

function getFilePath() {
    return path.dirname(fileURLToPath(import.meta.url));
}

Our function looks like this:

/**
 * Create the canvas, draw the board and pieces, then convert to PNG.
 * @param {string}  fen  FEN string 
 * @param {boolean} rev  if true, draw the board reversed 
 * @param {number}  size board size in pixels 
 * @return {buffer} buffer containing the PNG
 */
function draw(fen, rev, size) {
    const fPath1 = path.join('public', 'casefont.ttf'),
          fPath2 = path.join('public', 'roboto.ttf'),
          canvas = createCanvas(size, size),
          sqSize = size/8,
          ctx    = canvas.getContext('2d');

    GlobalFonts.registerFromPath(fPath1, 'Chess');
    GlobalFonts.registerFromPath(fPath2, 'Board');
    drawBoard(ctx, rev, sqSize);
    drawPieces(ctx, fen, rev, sqSize);

    return canvas.toBuffer('image/png');
}

We create the canvas and we register the fonts with GlobalFonts.registerFromPath. In this example, we assign public/roboto.ttf as the ‘Board’ font and the chess font as ‘Chess’.

We pass the canvas context to two other functions to draw the board and pieces and then we return the PNG.

Drawing the board

Now that we have the canvas, we can draw on it.

We have to draw 8 by 8 squares of alternating colors. On the first square in a row we have to write the rank number (1 to 8 or 8 to 1) and on each square of the bottom rank we need to write the file letter (A to H or H to A). Small fact: in chess, file letters are lowercase!

So, we need to convert a square number into row/col numbers and reverse if needed. Remember, the board, if drawn from white’s perspective, has the square numbers arranged like this:

56 57 58 59 60 61 62 63
48 49 50 51 52 53 54 55
...
0 1 2 3 4 5 6 7

In reverse (from black’s perspective), the representation becomes:

 7  6  5  4  3  2  1  0
15 14 13 12 11 10 9 8
...
63 62 61 60 59 58 57 56

Here’s the small function that converts square numbers to row+column:

/**
 * Convert a square number to row/column.
 * @param {number}  i   square number
 * @param {boolean} rev reverse?
 * @return [number, number] row and column
 */
function sqToCoords(i, rev) {
	let col = i % 8,
	    row = (i - col) / 8;

	if (rev) {
		col = 7 - col;
		row = 7 - row;
	}

	return [row, col]
}

So we need to convert the square number to a row/col (file/rank) pair and reverse if needed. Determining whether a square is light or dark can be done easily with this formula isDark = ((row + col) % 2 === 1).

Then we just draw squares of the required size and put the file/rank letters and numbers where needed:

/**
 * Draw the board.
 * @param {CanvasRenderingContext2D} ctx canvas context
 * @param {boolean}                  rev true for a reversed board (black down)
 * @param {number}                   sqSize square size
 */
function drawBoard(ctx, rev, sqSize) {
	const letters    = 'abcdefgh',
	      darkColor  = '#b5876b',
	      liteColor  = '#f0dec7',
	      fontSize   = Math.floor(sqSize/4);

	for (let i=0; i<64; i++) {
		const [row, col] = sqToCoords(i, rev);

		ctx.fillStyle = ((row + col) % 2 === 1) ? darkColor : liteColor;
		ctx.fillRect(col*sqSize, row*sqSize, sqSize, sqSize);

		ctx.font         = `${fontSize}px Board`;
		ctx.textBaseline = 'top';
		ctx.fillStyle    = '#000';

		if (col == 0)
			ctx.fillText((rev?row+1:8-row).toString(), col*sqSize+2, row*sqSize+2);

		if (row == 7)
			ctx.fillText(letters[rev?7-col:col], (col+1)*sqSize-fontSize/1.4, (row+1)*sqSize-fontSize*1.4);
	}
}

The board representation

The standard for representing the chessboard is a FEN string. FEN stands for Forsyth–Edwards Notation. Over a hundred years old, this notation allows us to quickly represent the board. I won’t get into detail, but for the sake of understanding what’s going on, a FEN string looks like this:

rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq c6 0 2

Lowercase characters are black, uppercase are white. K=King, Q=Queen and so on, with P standing for Pawn. The stuff after the first space is about the game state and does not concern us.

The board is read from the top left corner, left to right and downwards. A forward slash means skip to the next row. A numbers means that the next N squares on the row are empty. So “8/1p5R” can be read as “8 empty squares, then on the next row (rank), one empty square, a black pawn, then 5 more empty spaces followed by a white rook.

So the parser is very simple:

/**
 * Expand a FEN string into a 64-char array.
 * FEN strings look like this: 
 * `2r3k1/p4p2/3Rp2p/1p2P1pK/8/1P4P1/P3Q2P/1q6 b - - 0 1`
 * Note that we're only interested in the first group 
 * (before the first space).
 * @param  {string} fen FEN string
 * @return {string[]} board representation as a 64-element array
 */
function parseFEN(fen) {
    // create a 64-element array and fill it with spaces
    const board = [...new Array(64)].map(_n=>' ');

    let row = 0,
        col = 0;

    for (const chr of fen.split(' ')[0]) {
        // last row?
        if (row > 7)
            break;

        // skip to next row?
        if (chr == '/' || col > 7) {
            row++;
            col = 0;
            continue;
        }

        // skip column?
        if ('12345678'.includes(chr)) {
            col += parseInt(chr);
        }
        else
        // valid piece symbol?
        if ('kqrnbpKQRNBP'.includes(chr)) {
            board[row*8+col] = chr;
            col++;
        }
    }

    return board;
}
I won't go through each line since I wrote it to be very straightforward. The only line that might give some pause is this: [...new Array(64)].map(_n=>' ')

new Array(64) defines an array with 64 elements, but it’s not initialized. So next we use the spread operator (...) to expand the array into a new one (via the square brackets shorthand). Now we have a 64-element array and all its elements are undefined. Finally, we map this array into another one, setting each element to space.

As mentioned before, the standard representation for chess pieces is P=Pawn, N=kNight, B=Bishop, R=Rook, Q=Queen, K=King, with uppercase for white and lowercase for black. However, chess fonts are designed for black and white diagrams and have 4 variants for each piece: white or white, white on black, black on white and black on black. We need to pick the right character for each piece symbol. I’m using a very simple switch, because it’s legible, but other solutions may be used, like an object/map.

/**
 * Get the font character corresponding to the piece symbol.
 * @param {string} symbol (p, n, q, ...) 
 * @return {string} character
 */
function pieceToChar(p) {
    switch(p.toLowerCase()) {
        case 'p': return 'o';
        case 'n': return 'm';
        case 'b': return 'v';
        case 'r': return 't';
        case 'q': return 'w';
        case 'k': return 'l';
        default: return ' ';
    }
}

Adding the pieces

The final piece of the puzzle is adding the pieces. We use the previously-defined parseFEN to expand the FEN string into the full board. As before, we convert the square number to coordinates. Here we also make some adjustments so that each piece symbol sits nicely on its square. You may need to alter these settings a little, depending on the font metrics.

To enhance legibility, we draw the text (pieces) with an outline. Because the outline is always centered on the shape of the letter (we can’t specify advanced stroke features to place the stroke on the outside), we draw each symbol twice: first the outline, then the fill. This way the fill masks the inside of the stroke.

/**
 * Draw the pieces on the board.
 * @param {CanvasRenderingContext2D} ctx    canvas context
 * @param {string}                   fen    FEN string
 * @param {boolean}                  rev    reverse board?
 * @param {number}                   sqSize square size
 */
function drawPieces(ctx, fen, rev, sqSize) {
	const board    = parseFEN(fen),
	      fontSize = sqSize - 4;

	ctx.font         = `${fontSize}px/${sqSize}px Chess`;
	ctx.textBaseline = 'top';

	for (let i=0; i<64; i++) {
		const p = board[i];

		if (p == ' ')
			continue;

		ctx.fillStyle   = (p == p.toUpperCase()) ? '#fff' : '#000';
		ctx.strokeStyle = (p == p.toLowerCase()) ? '#fff' : '#000';
		ctx.lineWidth   = 2;

		const [row, col] = sqToCoords(i, rev);

		const s = pieceToChar(p),
			  x = col * sqSize + 2,
			  y = row * sqSize + sqSize/6;

		ctx.strokeText(s, x, y);
		ctx.fillText(s, x, y);
	}
}

And that’s it!

The complete code

You can find the complete code on my GitHub.

Picture of Armand Niculescu

Armand Niculescu

Senior Full-stack developer and graphic designer with over 25 years of experience, Armand took on many challenges, from coding to project management and marketing.

Leave a Reply

Your email address will not be published. Required fields are marked *