BlockAttack Part 3 - The requestAnimationFrame API and Framerate Independence

In the previous post in this series, we built an animation loop which moved a red block from the left side of the canvas to the right and then started over again. In this post, we will convert this to a more general game loop and further separate our game state from our rendering code.

Previously we were using the setTimeout Javascript function to call back to a drawFrame() method which would update and render the game state, and then call itself by using another setTimeout(). In this way, we were able to control the framerate by adjusting the delay used in the setTimeout call.

This is a method for achieving a game loop in Javascript without locking up the main thread and making the browser unresponsive. However, this method is not the most efficient or the easiest to use. There is a built-in Javascript function called requestAnimationFrame that we should use instead. Please refer to the discussion on MDN CSS and JavaScript animation performance to learn more about the differences between setTimeout, setInterval, and requestAnimationFrame. This built-in API is called before the paint of every frame and does not require us to maintain a delay parameter. Generally, this means it will be called 60 times a second on a 60hz monitor.

From the article linked above, it is important to note that "requestAnimationFrame() pauses when the current tab is pushed into the background.". This is great for battery life, as the browser will stop animating when the tab is not active, but it means that we need to consider this and account for the behavior of our game when it returns from the background. I can see this becoming an issue for our game, but right now I don't want to let it impede our progress. This is something we will defer until a later discussion.

Let's update our BlockAttack class to use requestAnimationFrame:

blockattack.js

class BlockAttack {
    width = 800;
    height = 800;
    blockSize = 80;

    blockPositionX = 0;

    constructor(canvas) {
        this.canvas = canvas;        
    }    

    run() {
        this.canvas.setAttribute('width', this.width);
        this.canvas.setAttribute('height', this.height);

        window.requestAnimationFrame((timestamp) => this.tick(timestamp));
    }

    tick(timestamp) {        
        this.updateState();
        this.drawFrame();        
    }

    updateState() {        
        this.blockPositionX = (this.blockPositionX < 9) ? this.blockPositionX + 1 : 0;
    }

    drawFrame() {
        const ctx = this.canvas.getContext("2d");

        // Clear the canvas
        ctx.clearRect(0, 0, this.width, this.height);

        // Draw the black grid strokes
        for (let i = 0; i < 10; i++) {
            ctx.strokeStyle = "#000000";
            ctx.strokeRect(i * 80, 0, this.blockSize, this.blockSize);
        }

        // Draw the current position of the red block
        ctx.fillStyle = "#FF0000";
        ctx.fillRect(this.blockPositionX * 80, 0, this.blockSize, this.blockSize);
        ctx.strokeStyle = "#000000";
        ctx.strokeRect(this.blockPositionX * 80, 0, this.blockSize, this.blockSize);

        window.requestAnimationFrame((timestamp) => this.tick(timestamp));
    }
}

There are a few important changes to point out here:

  1. A new method called tick() has been introduced which drives our game loop. Each time tick() is called, we call updateState() to perform game logic and then drawFrame() to draw the new game state to our canvas.
  2. The gridX variable has been renamed blockPositionX and moved to a member variable. It is now updated everytime updateState() is called
  3. run() and drawFrame() now call window.requestAnimationFrame instead of setTimeout(). At the end of a requestAnimationFrame callback, we must call requestAnimationFrame() again if we want to continue receiving callbacks.

This code functions correctly and animates the block across the screen but we have lost our 100ms delay between frames. Our animation progresses every time tick() is called which is 60 times a second on my machine. This means that the blockPositionX will be incremented every 1/60s and the block's position will move much faster than what we want. This raises the question - how do we make the speed of our block movemment and animation independent of the browser's framerate? The answer is that we need to design a framerate-independent game loop.

A lot has been said and written about building a framerate-independent game loop. I pulled together a few resources here and I encourage you to check them out:

The requestAnimationFrame API offers a tool for solving this problem. Each time requestAnimationFrame is called, the browser passes a timestamp to the callback, which allows the callback to detect how much time has elapsed since the last frame. Once you know the timespan "diff" since the last frame, you can make a decision about whether to advance your game logic or not.

Let's say that we want to lock our block animation to 10 frames-per-second (FPS). This would require us to wait 100ms before incrementing blockPostitionX because 1000ms / 100ms = 10, or 10 frames per second. To accomplish this, we need to track the timestamp of the previous blockPositionX increment and wait until enough time passes to increment it again.

class BlockAttack {
    ...    
    lastBlockMoveTimestamp = 0;
    blockPositionX = 0;    
    ...

    tick(timestamp) {        
        this.updateState(timestamp);
        this.drawFrame();        
    }

    updateState(timestamp) {      
        if (this.lastBlockMoveTimestamp == 0) {
            this.lastBlockMoveTimestamp = timestamp;
        }

        // milliseconds since the lastBlockMoveTimestamp
        const timeDiff = timestamp - this.lastBlockMoveTimestamp
        
        if (timeDiff >= 100) {
            this.blockPositionX = (this.blockPositionX < 9) ? this.blockPositionX + 1 : 0;
            this.lastBlockMoveTimestamp = timestamp;
        }       
    }    
    ...
}

The unchanged code in the BlockAttack class has been elided and replaced with "...". In our first change, we add a member variable lastBlockMoveTimestamp to store the last time we moved the block. Then, we modify tick() to pass the timestamp from the requestAnimationFrame into the updateState() method. Now, our updateState() method can compare the lastBlockMoveTimestamp with the current timestamp and if the difference is equal-to or greater-than 100ms, perform an update. The last line in updateState sets lastBlockMoveTimestamp to contain the current timestamp so that the process of waiting 100ms for the next grid move starts over again.

Our changes so far have achieved some important improvements to our code.

  • We are now using requestAnimationFrame to callback to our gameloop which is more efficient and provides built-in capability for managing timestamps.
  • We have developed a new framerate independent game loop which is capable of managing animations or game logic which run at any arbitrary time interval.
  • We have separated game logic updates from graphics rendering. This enables us to run each independently and at different framerates if we desire.

This is a small increment but I think it's sufficient progress for this post. Next time we will build out more of our game state management and start to render a full board with pieces that drop from top to bottom.

BlockAttack Repository commit for this blog post.

BlockAttack Repository

Play the Latest Version of BlockAttack at PlayBlockAttack.com

Thank you for reading!