DEV Community

loading...
Cover image for How to Create a Sprite Animation Without Canvas

How to Create a Sprite Animation Without Canvas

polluterofminds profile image Justin Hunter ・9 min read

When building a video game in JavaScript, or doing anything requiring animations beyond the normal capabilities of CSS transitions and keyframes, most people turn to the canvas element. It’s a versatile element that allows you to draw arbitrary shapes and images in both 2d and 3d. This is why almost every HTML5 game uses the canvas element. However, you may find yourself needing to build animations without canvas just as I found myself needing to do. Let’s dive into how we go about that, but first a quick explanation on why.

In my case, I was building a game that has to be 13kb or less as part of the JS13K game competition. It’s possible, of course, to do this with canvas. However, I found that when using the canvas element, you end up writing a lot more code than you would with normal DOM manipulation. In a competition where every byte counts, the less code you have to write, the better.

So, today, I'll walk you through how to use DOM elements, JavaScript, and the CSS background property to create animations in a game loop much like you would if using canvas. We're going to do this with no libraries, no dependencies, just good old fashion HTML, JS, and CSS. Let's get started!

Create Your Project.

I'm going to be referring to the MacOS terminal commands here (sorry Windows folks), but you can just as easily create a folder and files manually. First, we want to create our project folder:

mkdir animation-without-canvas

Once the folder has been created, you'll need to change into it like this:

cd animation-without-canvas

Now, let's create the one and only file we will be using for this tutorial. That's right. One file. Mind-blowing, I know.

touch index.html

Once you've done that, you're ready to get coding. Open your index.html file in your favorite text editor, and let's drop in some boilerplate HTML:

<!doctype html>

<html lang="en">
<head>
  <meta charset="utf-8">

  <title>Animation Without Canvas</title>
  <meta name="description" content="Animation Without Canvas">
</head>
<body>

</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Creating The Game Window

We're going to keep this very simple and create a div that will house our game window. We can do this within the body tags of our index.html file like so:

...
body>
  <div id='game'></div>
</body>
...
Enter fullscreen mode Exit fullscreen mode

There are a few different ways to handle sizing our game world. We could use inline CSS or we could put it in a stylesheet. Or we can programmatically update the game world size using variables that can easily be swapped out. We'll be taking the third option.

To do this, we need to create a script tag in our index.html file. Within that script tag, we will define our game width and game height.

<body>
  <div id='game'></div>
  <script>
    let WIDTH = 800;
    let HEIGHT = 600;
  </script>
</body>
Enter fullscreen mode Exit fullscreen mode

Now, we need to grab our game window element and set the width and height. But we need to do it only after our window loads. This is how we can accomplish that:

window.onload = () => {
  const world = document.getElementById('game');
  world.style.width = `${WIDTH}px`;
  world.style.height = `${HEIGHT}px`;   
  world.style.background = '#eee';
}
Enter fullscreen mode Exit fullscreen mode

Here we are telling our script to wait for the browser window to load. When it does, we are telling the script to grab our game element, set its width to the variable we created earlier and set its height to the variable we also created. And just to visualize this, I've added a background color. If you open your index.html file in a browser window, you should see a grey box.

Now that we've touched on finding elements and changing them programmatically, we can programmatically create an element which will represent our sprite. First we need to create two new variables that hold our sprite height and width. Below your existing variables, add this:

let SPRITE_HEIGHT = 25;
let SPRITE_WIDTH = 25;
Enter fullscreen mode Exit fullscreen mode

Now, we can create the sprite container element. Add the following code below the code we used to set our game width and height:

//  Create the sprite element
const sprite = document.createElement('div');
sprite.style.height = `${SPRITE_HEIGHT}px`;
sprite.style.width = `${SPRITE_WIDTH}px`;

world.appendChild(sprite);
Enter fullscreen mode Exit fullscreen mode

You won't see any changes on the screen yet, but we have created a container that will eventually show our sprite. We also created variables that can easily be adjusted should we need to change the size of our sprite (which is likely to happen).

Just to show that the sprite is actually within our game window, let's add a border. Below the sprite.style.width line, add this:

sprite.style.border = '1px solid #000';
Enter fullscreen mode Exit fullscreen mode

In the top-left corner of your game window, you'll see your sprite border. Now, we're ready to pull in an image called a sprite sheet to use for our sprite.

Adding a Sprite Sheet

Thanks to the wonderful site Open Game Art, we can grab a sprite sheet to use for this tutorial pretty easily. We're going to use this sprite sheet. This work was created by Charles Gabriel.

To import our image into the project, we're going to do a little bit of refactoring. We need to render an image of a sprite and animate it. So, we need to make sure the image is properly loaded before we try to do any animation. Let's start by first importing the image into our project. We can add the following to the end of our list of variables:

const img = new Image();
img.src = 'https://opengameart.org/sites/default/files/styles/medium/public/ATK-preview.png';
Enter fullscreen mode Exit fullscreen mode

We're creating a new Image element and assigning the sprite sheet from before to the image source. Now, we're going to do our tiny bit of refactoring. If you remember, we wanted to execute the code in our script only after the window loaded. Now, however, we want to first make sure our image is loaded. We can refactor our code to look like this:

window.onload = () => {
  img.src = 'https://opengameart.org/sites/default/files/styles/medium/public/ATK-preview.png';
}    

img.onload = () => {
  const world = document.getElementById('game');
  world.style.width = `${WIDTH}px`;
  world.style.height = `${HEIGHT}px`;
  world.style.background = '#eee';

  //  Create the sprite element
  const sprite = document.createElement('div');
  sprite.style.height = `${SPRITE_HEIGHT}px`;
  sprite.style.width = `${SPRITE_WIDTH}px`;
  sprite.style.border = '1px solid #000';

  world.appendChild(sprite);
}
Enter fullscreen mode Exit fullscreen mode

We are waiting for the window to load then assigning the image source. We then wait for the image to load before executing any other code.

Now, let's see what happens when we assign our image as a background for our sprite. First, let's figure out how big our sprite is. To do this, we need to know the height and width of each "frame" of the sprite sheet. This is as simple as taking the entire sprite sheet width and dividing by the number of images wide you see, then taking the sprite sheet height and dividing by the number of images tall you see. Adjust the SPRITE_HEIGHT variable to be 20 and the SPRITE_WIDTH variable to be 18.

Now, you can add this above world.appendChild(sprite):

sprite.style.backgroundImage = `url(${img.src})`;
Enter fullscreen mode Exit fullscreen mode

What you should see if you open your index.html file in your browser is this:

This is interesting. We know that the sprite sheet has many images of our sprite, but we only see the one facing away from us. This is because we set the container element that houses our sprite to a certain width and height. So, when we apply the sprite image as a background image, we can only show that much of the total image. This is a good start to our animation, but it's still not exactly what we'll need.

Working With Background Images

When animating on cavnas, the approach is to draw only part of the overall sprite sheet. We're going to essentially do the same thing by making use of the background-position property in CSS.

Let's test this out by adding the following beneath the line where we assign the background image:

sprite.style.backgroundPosition = '18px 20px';
Enter fullscreen mode Exit fullscreen mode

What happened here? Well, according to MDN, the CSS background-image property takes parameters that describe what part of the background should be rendered. In this case, we told the script to render the background position on the x-axis at 18px and the y-axis at 20px. This is a bit confusing at first, so take a look at the grid overlay I created below:

An example of the sprite sheet with a pixel grid overlaid

The best way to think about this is we are counting our x and y coordinates from the bottom-right. Knowing this will help us a ton as we create our animation loop. We need to create a function we can call that will allow us to render the correct sprite frame based on our animation, but first, we need to hoist our sprite variable.

Right now, our sprite variable is declared within the img.onload function. That's fine for now, but we will need to easily access our sprite variable, so creating a global variable makes sense. Find the line that says const sprite = document.createElement('div'); and remove the const. Now under your other variables at the top of the script add: let sprite;

We defined the sprite variable but did not assign it to anything. That means it first get assigned in the img.onload function and we can then do other things to that variable later.

Drawing Our Sprite

As I mentioned before, we need to create a function that will allow us to draw the correct frame for our sprite on the screen. This means we will be adjusting the background-position property on the sprite frequently. Let's start by creating a function under our global variables like this:

const drawSprite = (frameX, framey) => {
  const x = frameX * SPRITE_WIDTH;
  const y = frameY * SPRITE_HEIGHT;
  sprite.style.backgroundPosition = `${x}px ${y}px`;
}
Enter fullscreen mode Exit fullscreen mode

This is, again, counting frames from the bottom-right. It's kind of odd, but if you refer back to the grid I created, it'll make sense. So the bottom-right frame in the sprite sheet would be (1, 1). We need to multiply the frame by the sprite height and the sprite width to make sure we get the full sprite image in the frame.

Let's make sure this works by drawing the bottom-right frame using this method. Replace this line sprite.style.backgroundPosition with this: drawSprite(1, 1).

You should get the same result as before.

Ok, we have the basics down. Now, let's animate this thing!

Animation Time

We can render one frame on the screen and that's pretty cool, but what we really want is the illusion of movement. We want animation. To achieve this, we will make use of the requestAnimationFrame function that is built into JavaScript.

This function creates a loop that repeatedly calls a function with each "animation frame". Browsers can usually render 60 frames per second. So, whatever function you pass into the requestAnimationFrame method will be called that often. We'll create the function we will pass in now.

Right below your drawSprite function, add the following:

const loop = () => {

}
Enter fullscreen mode Exit fullscreen mode

We'll add some code inside that function soon, but we have some variables to define first. At the end of your list of global variables add these:

let currentLoopIndex = 0;
const animationLoop = [1, 2, 3, 4]
Enter fullscreen mode Exit fullscreen mode

Here we are setting an index that we will update in our loop. That index will be used to pick out a frame from the other variable we've defined: animationLoop.

If you look at our sprite sheet and if you remember the bottom-right is the first frame, are animationLoop array is choosing the four bottom frames in the sprite sheet.

Ok, now let's use this in our loop function:

if(currentLoopIndex < animationLoop.length) {
  drawSprite(animationLoop[currentLoopIndex], 1);
  currentLoopIndex++
} else {
  currentLoopIndex = 0;
}

window.requestAnimationFrame(loop);
Enter fullscreen mode Exit fullscreen mode

We are telling the loop function to cycle through each frame defined in our animationLoop and draw our sprite with the specified frame. Something to note: Because we are always using the bottom row of our sprite sheet, the frameY variable in our drawSprite function is always one. You may have situations where you need to loop through an array of x positions and y positions, but we're keeping it simple here.

This code isn't going to do anything yet because we haven't told the program to execute it. At the end of our img.onload function, add this:

window.requestAnimationFrame(loop)

You should see this:

We just animated a sprite using regular DOM manipulation! That's pretty awesome, but it's moving pretty quick. Remember, browsers generally render at 60 frames-per-second. Let's slow down the animation to make our sprite "move" a little slower. Add these two variables to the end of your global variables:

let slowedBy = 0;
let slowFrameRate = 10;
Enter fullscreen mode Exit fullscreen mode

Now in the loop function, let's adjust things a bit to slow down the animation:

if (slowedBy >= slowFrameRate) {
  if (currentLoopIndex < animationLoop.length) {
    drawSprite(animationLoop[currentLoopIndex], 1);
    currentLoopIndex++;
  } else {
    currentLoopIndex = 0;
  }
  slowedBy = 0;
} else {
  slowedBy++;
}

window.requestAnimationFrame(loop);
Enter fullscreen mode Exit fullscreen mode

We've now slowed our animation down enough to see our sprite punching away. You can easily adjust the speed at which the sprite punches by changing the slowFrameRate variable.

With this all said and done, your sprite animation should look like this:

I hope you enjoyed this article. Special hat tip to Martin Himmel and his article on animating sprites using canvas. This article took a lot of inspiration from that one.

Discussion

pic
Editor guide
Collapse
ender_minyard profile image
ender minyard

Justin, it's great to hear from you! Did you know you're the reason I discovered dev.to? Hope all is well.

Collapse
polluterofminds profile image
Justin Hunter Author

I’m so glad to hear you discovered dev.to from my writing. It’s my favorite community!