Character, Movement and Input on a Tile Map

Fri May 02 2025 15:06:07 GMT+0100 (British Summer Time) canvasgamedevtilemaptutorialjavascripthtmlarchived

The Character Class

In this lesson, we'll expand on our project to include a character which we can move about. This will involve creating a character object, listening and storing keypresses for the arrow keys, adding movement processing, and drawing the character.

View example

We'll start by adding a map of keyCodes to boolean flags to tell us which of the arrow keys are currently pressed. The keyCodes 37 to 40 represent the arrow keys Left, Up, Right, and Down, in that order. To begin with, we'll assume that none of these keys are pressed and set all the flags to false.


var keysDown = {
    37 : false,
    38 : false,
    39 : false,
    40 : false
};

We'll also add another global variable - player, which will store an instance of the new Character object which we'll create in a moment. In Javascript, we can initialize variables before the class has been defined in the code; whilst not good practice, it'll work fine in this situation as we're doing everything in a single file.

var player = new Character();

The Character class and its methods

We'll now create a Character class. This will store information about the player we create, and allow us to modify it through the methods we'll create in a moment. We're doing this as a class instead of creating a single Javascript object so that we can add more Characters later on.

The Character class will store the coordinates of the tiles the Character is currently moving from and to (tileFrom, tileTo), the time (in milliseconds) at which the Character began to move (timeMoved), the dimensions of the Character in pixels (dimensions), the true position of the Character on the Canvas in pixels (position), and the time (in milliseconds) it will take the Character to move 1 tile (delayMove).


function Character()
{
    this.tileFrom    = [1,1];
    this.tileTo        = [1,1];
    this.timeMoved    = 0;
    this.dimensions    = [30,30];
    this.position    = [45,45];
    this.delayMove    = 700;
}

We're going to give out Character a placeAt method, which will allow us to place it directly on the tile we specify. This will help with initially placing the character, and resetting movement related properties to the new tile.


Character.prototype.placeAt = function(x, y)
{
    this.tileFrom    = [x,y];
    this.tileTo        = [x,y];
    this.position    = [((tileW*x)+((tileW-this.dimensions[0])/2)),
        ((tileH*y)+((tileH-this.dimensions[1])/2))];
};

Our function takes two arguments; the x and y position of the destination tile. It then updates the tileFrom and tileTo properties to the new tile coordinates, and calculates the pixel position of the character using the following method:

[character position] = [
    (([tile width] x [destination X coord]) + (([tile width] - [character width]) / 2)),
    (([tile height] x [destination Y coord]) + (([tile height] - [character height]) / 2))
];

In simple terms, the horizontal and vertical positions of the character are the same offsets at which the tile is drawn, adding half the difference between the character width (or height) and the tile width (or height).

Moving the Character

Processing movement

If our Character is moving, we need to do some calculations each frame to find the true position. We'll pass this method the current game time in milliseconds, and we'll check straight away if the characters destination (tileTo is the same as its current tile (tileFrom). If this is the case, we know the Character is not currently moving, so we leave the function by returning false to let the code that called the method know the Character is free to receive new instruction...


Character.prototype.processMovement = function(t)
{
    if(this.tileFrom[0]==this.tileTo[0] && this.tileFrom[1]==this.tileTo[1]) { return false; }

We'll next check if the amount of time that has passed since the Character began its current movement is equal to or longer than the amount of time it takes this Character to move 1 tile. If it is, we set the Characters position to its destination tile using the placeAt method:


    if((t-this.timeMoved)>=this.delayMove)
    {
        this.placeAt(this.tileTo[0], this.tileTo[1]);
    }

If the above checks let us know that the Character is in fact still moving, we'll need to accurately calculate its current position on the canvas. To start with, we can calculate the position of the tile the Character is moving from (tileFrom).


    else
    {
        this.position[0] = (this.tileFrom[0] * tileW) + ((tileW-this.dimensions[0])/2);
        this.position[1] = (this.tileFrom[1] * tileH) + ((tileH-this.dimensions[1])/2);

Then, starting with the horizontal, or x coordinate, we'll see if the Character is moving on this axis. If so, we calculate the number of pixels they have moved by dividing the width of a tile by the time it takes the Character to move 1 tile, and multiplying the result by the amount of time that has passed since the Character began moving:

[distance moved] = ([tile width] / [time to move 1 tile]) x ([current time] - [time movement began])

The code uses the variable diff to calculate the distance moved, and then depending on whether the destination tile (tileTo) is left (or above), or right (or below) the tile we are moving from (tileFrom), we subtract or add this amount to the Characters position. We repeat this code for Y (or vertical) Character position.


        if(this.tileTo[0] != this.tileFrom[0])
        {
            var diff = (tileW / this.delayMove) * (t-this.timeMoved);
            this.position[0]+= (this.tileTo[0]<this.tileFrom[0] ? 0 - diff : diff);
        }
        if(this.tileTo[1] != this.tileFrom[1])
        {
            var diff = (tileH / this.delayMove) * (t-this.timeMoved);
            this.position[1]+= (this.tileTo[1]<this.tileFrom[1] ? 0 - diff : diff);
        }

After we've updated the position, we'll round the x and y values to the nearest whole number - this helps to smooth the drawing on our Canvas:


        this.position[0] = Math.round(this.position[0]);
        this.position[1] = Math.round(this.position[1]);

We can now close this function and return true, to let the code that called this function know that the Character is currently moving.


    }

    return true;
}

User Keyboard Input

Modifying our existing code

We'll also have to make some changes to our window onload function and our drawGame function. Before we do that, we'll create a simple function to make some of our code more readable which will convert a coordinate (x, y) to the corresponding index in the gameMap array.


function toIndex(x, y)
{
    return((y * mapW) + x);
}

Now we can modify our window onload function. We'll add eventListeners for the keydown and keyup events to turn the flags in the keysDown map on or off (true or false) if the keys pressed/released are the arrow keys.


    window.addEventListener("keydown", function(e) {
        if(e.keyCode>=37 && e.keyCode<=40) { keysDown[e.keyCode] = true; }
    });
    window.addEventListener("keyup", function(e) {
        if(e.keyCode>=37 && e.keyCode<=40) { keysDown[e.keyCode] = false; }
    });

If an arrow key, ie; a key with the keyCode 37 to 40, is pressed (keydown), we store a true in the keysDown array; when an arrow key is released (keyup), we set the corresponding array value to false.

The event listeners are attached to the window so they fire whenever this window has focus once the page has loaded.

We will now also modify the drawGame function to store some more time related information; the current time in milliseconds (currentFrameTime), and the amount of time in milliseconds since the last frame was processed (timeElapsed). This will be inserted straight after our Canvas drawing context ctx validation.


    var currentFrameTime = Date.now();
    var timeElapsed = currentFrameTime - lastFrameTime;

Player movement & Input

Now we need to look at player movement, and processing the key inputs we are capturing. We'll put this bit of code in the drawGame function, after our framerate calculations. First, we'll begin by calling the Character processMovement method for the player with the current time in milliseconds (currentFrameTime); if no movement is currently being processed, we see if we should give the Character new movement instruction:


    if(!player.processMovement(currentFrameTime))
    {

We now have 4 if statements that perform a check for the Left, Right, Up and Down directions respectively, to see if all of the following statements are true:

  • The corresponding Arrow Key is pressed
  • The destination tile is in map bounds; greater than or equal to 0, and less than map width (or map height, depending on axis). This is done by checking the current tile (tileFrom) when modified by 1 (minus 1 to X or Y for Left or Up, or plus 1 to X or Y for Right or Down) falls within these bounds.
  • The destination tiles value in the gameMap array is 1, as we are treating tile with a value of 1 as traversable.

        if(keysDown[38] && player.tileFrom[1]>0 && gameMap[toIndex(player.tileFrom[0], player.tileFrom[1]-1)]==1) { player.tileTo[1]-= 1; }
        else if(keysDown[40] && player.tileFrom[1]<(mapH-1) && gameMap[toIndex(player.tileFrom[0], player.tileFrom[1]+1)]==1) { player.tileTo[1]+= 1; }
        else if(keysDown[37] && player.tileFrom[0]>0 && gameMap[toIndex(player.tileFrom[0]-1, player.tileFrom[1])]==1) { player.tileTo[0]-= 1; }
        else if(keysDown[39] && player.tileFrom[0]<(mapW-1) && gameMap[toIndex(player.tileFrom[0]+1, player.tileFrom[1])]==1) { player.tileTo[0]+= 1; }

If one of these statements is true, we update the tileTo coordinates to reflect the desired destination. As we are restricting movement to one tile at a time with no diagonal movement, we use else/if to ensure only one statement can be validated as true.

Afterwards, we do a quick test to see if the destination (tileTo) is now different from the current tile (tileFrom). If so, we update the player timeMoved to the currentFrameTime, and close this input processing block:


        if(player.tileFrom[0]!=player.tileTo[0] || player.tileFrom[1]!=player.tileTo[1])
        { player.timeMoved = currentFrameTime; }
    }

Drawing the player

Finally, we want to draw our new player on the Canvas. We'll draw the player after we've drawn the tiles, so it's not hidden, but before we draw the frame rate. After our nested for X, for Y tile drawing loops have closed, we'll add this simple code:


    ctx.fillStyle = "#0000ff";
    ctx.fillRect(player.position[0], player.position[1],
        player.dimensions[0], player.dimensions[1]);

Try it out! Load this up in your browser, and you should now have a little blue square you can move around your map with the arrow keys.

Code showing modifications

<!DOCTYPE html>
<html>
<head>

<script type="text/javascript">
var ctx = null;
var gameMap = [
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    0, 1, 1, 1, 0, 1, 1, 1, 1, 0,
    0, 1, 0, 0, 0, 1, 0, 0, 0, 0,
    0, 1, 1, 1, 1, 1, 1, 1, 1, 0,
    0, 1, 0, 1, 0, 0, 0, 1, 1, 0,
    0, 1, 0, 1, 0, 1, 0, 0, 1, 0,
    0, 1, 1, 1, 1, 1, 1, 1, 1, 0,
    0, 1, 0, 0, 0, 0, 0, 1, 0, 0,
    0, 1, 1, 1, 0, 1, 1, 1, 1, 0,
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0
];
var tileW = 40, tileH = 40;
var mapW = 10, mapH = 10;
var currentSecond = 0, frameCount = 0, framesLastSecond = 0, lastFrameTime = 0;

var keysDown = {
    37 : false,
    38 : false,
    39 : false,
    40 : false
};

var player = new Character();

function Character()
{
    this.tileFrom    = [1,1];
    this.tileTo        = [1,1];
    this.timeMoved    = 0;
    this.dimensions    = [30,30];
    this.position    = [45,45];
    this.delayMove    = 700;
}
Character.prototype.placeAt = function(x, y)
{
    this.tileFrom    = [x,y];
    this.tileTo        = [x,y];
    this.position    = [((tileW*x)+((tileW-this.dimensions[0])/2)),
        ((tileH*y)+((tileH-this.dimensions[1])/2))];
};
Character.prototype.processMovement = function(t)
{
    if(this.tileFrom[0]==this.tileTo[0] && this.tileFrom[1]==this.tileTo[1]) { return false; }

    if((t-this.timeMoved)>=this.delayMove)
    {
        this.placeAt(this.tileTo[0], this.tileTo[1]);
    }
    else
    {
        this.position[0] = (this.tileFrom[0] * tileW) + ((tileW-this.dimensions[0])/2);
        this.position[1] = (this.tileFrom[1] * tileH) + ((tileH-this.dimensions[1])/2);

        if(this.tileTo[0] != this.tileFrom[0])
        {
            var diff = (tileW / this.delayMove) * (t-this.timeMoved);
            this.position[0]+= (this.tileTo[0]<this.tileFrom[0] ? 0 - diff : diff);
        }
        if(this.tileTo[1] != this.tileFrom[1])
        {
            var diff = (tileH / this.delayMove) * (t-this.timeMoved);
            this.position[1]+= (this.tileTo[1]<this.tileFrom[1] ? 0 - diff : diff);
        }

        this.position[0] = Math.round(this.position[0]);
        this.position[1] = Math.round(this.position[1]);
    }

    return true;
}

function toIndex(x, y)
{
    return((y * mapW) + x);
}

window.onload = function()
{
    ctx = document.getElementById('game').getContext("2d");
    requestAnimationFrame(drawGame);
    ctx.font = "bold 10pt sans-serif";

    window.addEventListener("keydown", function(e) {
        if(e.keyCode>=37 && e.keyCode<=40) { keysDown[e.keyCode] = true; }
    });
    window.addEventListener("keyup", function(e) {
        if(e.keyCode>=37 && e.keyCode<=40) { keysDown[e.keyCode] = false; }
    });
};

function drawGame()
{
    if(ctx==null) { return; }

    var currentFrameTime = Date.now();
    var timeElapsed = currentFrameTime - lastFrameTime;

    var sec = Math.floor(Date.now()/1000);
    if(sec!=currentSecond)
    {
        currentSecond = sec;
        framesLastSecond = frameCount;
        frameCount = 1;
    }
    else { frameCount++; }

    if(!player.processMovement(currentFrameTime))
    {
        if(keysDown[38] && player.tileFrom[1]>0 && gameMap[toIndex(player.tileFrom[0], player.tileFrom[1]-1)]==1) { player.tileTo[1]-= 1; }
        else if(keysDown[40] && player.tileFrom[1]<(mapH-1) && gameMap[toIndex(player.tileFrom[0], player.tileFrom[1]+1)]==1) { player.tileTo[1]+= 1; }
        else if(keysDown[37] && player.tileFrom[0]>0 && gameMap[toIndex(player.tileFrom[0]-1, player.tileFrom[1])]==1) { player.tileTo[0]-= 1; }
        else if(keysDown[39] && player.tileFrom[0]<(mapW-1) && gameMap[toIndex(player.tileFrom[0]+1, player.tileFrom[1])]==1) { player.tileTo[0]+= 1; }

        if(player.tileFrom[0]!=player.tileTo[0] || player.tileFrom[1]!=player.tileTo[1])
        { player.timeMoved = currentFrameTime; }
    }

    for(var y = 0; y < mapH; ++y)
    {
        for(var x = 0; x < mapW; ++x)
        {
            switch(gameMap[((y*mapW)+x)])
            {
                case 0:
                    ctx.fillStyle = "#000000";
                    break;
                default:
                    ctx.fillStyle = "#ccffcc";
            }

            ctx.fillRect( x*tileW, y*tileH, tileW, tileH);
        }
    }

    ctx.fillStyle = "#0000ff";
    ctx.fillRect(player.position[0], player.position[1],
        player.dimensions[0], player.dimensions[1]);

    ctx.fillStyle = "#ff0000";
    ctx.fillText("FPS: " + framesLastSecond, 10, 20);

    lastFrameTime = currentFrameTime;
    requestAnimationFrame(drawGame);
}
</script>

</head>
<body>

<canvas id="game" width="400" height="400"></canvas>

</body>
</html>

Character and Input source code


<!DOCTYPE html>
<html>
<head>

<script type="text/javascript">
var ctx = null;
var gameMap = [
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    0, 1, 1, 1, 0, 1, 1, 1, 1, 0,
    0, 1, 0, 0, 0, 1, 0, 0, 0, 0,
    0, 1, 1, 1, 1, 1, 1, 1, 1, 0,
    0, 1, 0, 1, 0, 0, 0, 1, 1, 0,
    0, 1, 0, 1, 0, 1, 0, 0, 1, 0,
    0, 1, 1, 1, 1, 1, 1, 1, 1, 0,
    0, 1, 0, 0, 0, 0, 0, 1, 0, 0,
    0, 1, 1, 1, 0, 1, 1, 1, 1, 0,
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0
];
var tileW = 40, tileH = 40;
var mapW = 10, mapH = 10;
var currentSecond = 0, frameCount = 0, framesLastSecond = 0, lastFrameTime = 0;

var keysDown = {
    37 : false,
    38 : false,
    39 : false,
    40 : false
};

var player = new Character();

function Character()
{
    this.tileFrom    = [1,1];
    this.tileTo        = [1,1];
    this.timeMoved    = 0;
    this.dimensions    = [30,30];
    this.position    = [45,45];
    this.delayMove    = 700;
}
Character.prototype.placeAt = function(x, y)
{
    this.tileFrom    = [x,y];
    this.tileTo        = [x,y];
    this.position    = [((
tileW*x)+((tileW-this.dimensions[0])/2)),
        ((tileH*y)+((tileH-this.dimensions[1])/2))];
};
Character.prototype.processMovement = function(t)
{
    if(this.tileFrom[0]==this.tileTo[0] && this.tileFrom[1]==this.tileTo[1]) { return false; }

    if((t-this.timeMoved)>=this.delayMove)
    {
        this.placeAt(this.tileTo[0], this.tileTo[1]);
    }
    else
    {
        this.position[0] = (this.tileFrom[0] * tileW) + ((tileW-this.dimensions[0])/2);
        this.position[1] = (this.tileFrom[1] * tileH) + ((tileH-this.dimensions[1])/2);

        if(this.tileTo[0] != this.tileFrom[0])
        {
            var diff = (tileW / this.delayMove) * (t-this.timeMoved);
            this.position[0]+= (this.tileTo[0]<this.tileFrom[0] ? 0 - diff : diff);
        }
        if(this.tileTo[1] != this.tileFrom[1])
        {
            var diff = (tileH / this.delayMove) * (t-this.timeMoved);
            this.position[1]+= (this.tileTo[1]<this.tileFrom[1] ? 0 - diff : diff);
        }

        this.position[0] = Math.round(this.position[0]);
        this.position[1] = Math.round(this.position[1]);
    }

    return true;
}

function toIndex(x, y)
{
    return((y * mapW) + x);
}

window.onload = function()
{
    ctx = document.getElementById('game').getContext("2d");
    requestAnimationFrame(drawGame);
    ctx.font = "bold 10pt sans-serif";

    window.addEventListener("keydown", function(e) {
        if(e.keyCode>=37 && e.keyCode<=40) { keysDown[e.keyCode] = true; }
    });
    window.addEventListener("keyup", function(e) {
        if(e.keyCode>=37 && e.keyCode<=40) { keysDown[e.keyCode] = false; }
    });
};

function drawGame()
{
    if(ctx==null) { return; }

    var currentFrameTime = Date.now();
    var timeElapsed = currentFrameTime - lastFrameTime;

    var sec = Math.floor(Date.now()/1000);
    if(sec!=currentSecond)
    {
        currentSecond = sec;
        framesLastSecond = frameCount;
        frameCount = 1;
    }
    else { frameCount++; }

    if(!player.processMovement(currentFrameTime))
    {
        if(keysDown[38] && player.tileFrom[1]>0 && gameMap[toIndex(player.tileFrom[0], player.tileFrom[1]-1)]==1) { player.tileTo[1]-= 1; }
        else if(keysDown[40] && player.tileFrom[1]<(mapH-1) && gameMap[toIndex(player.tileFrom[0], player.tileFrom[1]+1)]==1) { player.tileTo[1]+= 1; }
        else if(keysDown[37] && player.tileFrom[0]>0 && gameMap[toIndex(player.tileFrom[0]-1, player.tileFrom[1])]==1) { player.tileTo[0]-= 1; }
        else if(keysDown[39] && player.tileFrom[0]<(mapW-1) && gameMap[toIndex(player.tileFrom[0]+1, player.tileFrom[1])]==1) { player.tileTo[0]+= 1; }

        if(player.tileFrom[0]!=player.tileTo[0] || player.tileFrom[1]!=player.tileTo[1])
        { player.timeMoved = currentFrameTime; }
    }

    for(var y = 0; y < mapH; ++y)
    {
        for(var x = 0; x < mapW; ++x)
        {
            switch(gameMap[((y*mapW)+x)])
            {
                case 0:
                    ctx.fillStyle = "#685b48";
                    break;
                default:
                    ctx.fillStyle = "#5aa457";
            }

            ctx.fillRect( x*tileW, y*tileH, tileW, tileH);
        }
    }

    ctx.fillStyle = "#0000ff";
    ctx.fillRect(player.position[0], player.position[1],
        player.dimensions[0], player.dimensions[1]);

    ctx.fillStyle = "#ff0000";
    ctx.fillText("FPS: " + framesLastSecond, 10, 20);

    lastFrameTime = currentFrameTime;
    requestAnimationFrame(drawGame);
}
</script>

</head>
<body>

<canvas id="game" width="400" height="400"></canvas>

</body>
</html>