How Keyboard Navigation Works in a CSS Game

We’re going to build a “Super CSS Mario” game where you can use a keyboard and the arrow keys to move Mario around. Go ahead and play it to check it out. Note there is no JavaScript at all, it’s just HTML and CSS.

Why bother with a CSS-only game?

Creating a CSS-only game is a fun exercise. Restricting yourself to only HTML & CSS allows you to discover and unlock CSS trickery you can add to your toolbox. That’s what happens for me!

You might think that limiting yourself to CSS for all the functionality of the game is useless. CSS is not designed for this sort of thing, it’s for layouts and design control. But doing unusual and unexpected things in CSS is a great way to practice, and will lead to a deeper understanding of the language, making you a better CSS developer all around.

Interactivity without a Mouse

Many pure CSS games you will see around are playable mostly with a mouse. They rely on interactive elements such as checkboxes and pseudo-classes like :hover:active, :checked, and so on. But with recent CSS features, a keyboard control game (beyond tabbing) is also doable using CSS!

Cool right? Stay with me if you want to know the secret behind creating this game (and a few others at the end).

At the time of writing this, only Chrome (and Edge) have the full support of the features we will be using so consider those browsers to read the article.

For the sake of simplicity, I will skip the aesthetic parts. I will mainly focus on the techniques used to build the game. The code of demos used in the article may differ slightly from the real code used in the game.

Let’s start with this basic setup:

We have a container with an overflowing content that will trigger both the vertical and horizontal scrolling. Nothing fancy so far but let’s not forget that, in addition to the mouse, we can scroll the container using the direction keys. Try it! Click the container (above) then use the keyboard to scroll inside it.

Now let’s add two more elements inside the overflowing div to have the following code:

<div class="container">
  <div>
    <div class="game">
      <div class="mario"></div>
    </div>
  </div>
</div>Code language: HTML, XML (xml)

Then we make the .game element sticky so it doesn’t move when we scroll the container:

The magic touch now is to introduce scroll-driven animations to move our character. We can scroll the outer container but everything else stays fixed. By adding scroll-driven animations we can control the movement of Mario as we want.

It may sound tricky, but the code is pretty simple:

.mario {
  position: relative;
  top: 0%;
  left: 0%;
  animation: 
    x linear,
    y linear;
  animation-timeline: 
    scroll(nearest inline),
    scroll(nearest block);
}
@keyframes x { to { left: 100% } }
@keyframes y { to { top: 100%  } }Code language: CSS (css)

We have two animations. Each controls the movement in one direction (horizontal or vertical). Then we link them with the scrolling of the outer container. The x animation will follow the “inline” scroll (horizontal) and the y animation will follow the ”block” scroll (vertical).

In other words, the scrolling will define the progress of the animation. Try it:

We have our keyboard control!

We can still use the mouse to manipulate the scrollbars but if we hide them, the illusion is perfect!

.container {
  scrollbar-width: none;
}Code language: CSS (css)

You still need to click inside the container to get the focus before using the keyboard. I add the tabindex attribute to the main container so you can get the focus using the “tab” key as well.

The game can be playable using only the keyboard. Here is the link for the full game again to test it. Either use the mouse or click “tab” to start the game.

Adding The Coins

Could we add coins on the screen and then have Mario “collect” them when they touch? Well… no, not really. CSS does not have “collision detection” (yet?). So let’s fake it!

Since we’re controlling the location of Mario with animations, we can know where he is located. We are going to rely on this information to simulate collision detection between Mario and the coins.

To start, let’s place a coin inside the game board:

<div class="container">
  <div>
    <div class="game">
      <div class="mario"></div>
      <div class="coin"></div>
    </div>
  </div>
</div>
Code language: HTML, XML (xml)

And style it like this:

.coin {
  position: absolute;
  inset: 0;
}
.coin:before {
  content: "";
  position: absolute;
  width: 50px;
  left: calc(50% - 25px);
  top: calc(50% - 25px);
  aspect-ratio: 1;
}Code language: CSS (css)

The .coin container will fill the whole area of .game (we will see later why) and its pseudo-element is the visible coin:

The coin is placed at the center and to reach the center Mario needs to scroll half the distance vertically and horizontally which means it needs to reach half the x and y animations.

We use this information to define new animations that we link to the coin element like this:

.coin {
  animation: 
    c-x linear,
    c-y linear;
  animation-timeline: 
    scroll(nearest inline),
    scroll(nearest block);
}
@keyframes c-x {
  0% , 44%  {--c-x: 0}
  45%, 55%  {--c-x: 1}
  56%, 100% {--c-x: 0}
}
@keyframes c-y {
  0% , 44%  {--c-y: 0}
  45%, 55%  {--c-y: 1}
  56%, 100% {--c-y: 0}
}Code language: CSS (css)

This is the same animation configuration we used for Mario. One animation is linked to the horizontal scroll and another one is linked to the vertical scroll. Each animation will control a variable that will be either 0 or 1 based on the keyframes percentage.

The coin is placed at the center so we need the variable to turn 1 when the animation is around 50%. I am considering an offset of 5% to illustrate the idea but in the real code, I am using more accurate values.

Now, we will introduce another CSS feature: style queries. It allows us to conditionally apply specific CSS based on the value of custom properties (CSS variables). Style queries require a parent-child relation, so that’s why the real coin is the pseudo element of .coin container.

.coin {
  container-name: c;
}
@container c style(--c-x: 1) and style(--c-y: 1) {
  .coin:before {
     /* do what you want here */
  }
}Code language: CSS (css)

The previous animations will make both variables equal to 1 at 50% (when Mario is at the center) and the style query will apply a specific CSS when both variables are equal to 1.

In the below example, when Mario is above the coin, a red background will appear. We have our collision detection!

As I said previously, this is not super accurate. I am keeping this simple to illustrate the idea. In the real code, I am using more precise calculations to get a perfect collision detection.

What we did until now is good but we need better. The red color is only visible when Mario touches the coin but we need a way to maintain this state. In other words, if it turns red, it should stay red.

To achieve this, we have to introduce another animation and update the code like below:

.coin {
  container-name: c;
}
.coin:before {
  animation: touch .1s forwards linear var(--s, paused);
}
@keyframes touch {
  1%, 100% { background-color: red; }
}
@container c style(--c-x: 1) and style(--c-y: 1) {
  .coin:before {
     --s: running
  }
}Code language: CSS (css)

We define an animation that is “paused” initially and when the condition is met we make it “running”. I am using a small duration and a forwards configuration to make sure the red color stays even when Mario moves away from the coin.

With this configuration, we can add an animation that makes the coin disappear (instead of just the color change).

To add more coins, we add more .coin elements with different positions and animations. If you check the real code of the game you will find that I am defining different variables and using Sass to generate the code. I am using a grid system where I can control the number of columns and rows and I am defining another variable for the number of coins. Then with the help of the random() function from Sass I can randomly place the coins inside the grid.

The important thing to notice is how the HTML code is organized. We don’t do the following:

<div class="container">
  <div>
    <div class="game">
      <div class="mario"></div>
      <div class="coin"></div>
      <div class="coin"></div>
      <div class="coin"></div>
      ...
    </div>
  </div>
</div>Code language: HTML, XML (xml)

But rather the following:

<div class="container">
  <div>
    <div class="game">
      <div class="mario"></div>
      <div class="coin">     
        <div class="coin">
          <div class="coin">
          </div>
        </div>
      </div>
    </div>
  </div>
</div>Code language: HTML, XML (xml)

The .coin elements should not be siblings but nested inside each other. I need this configuration to later calculate the score. For this reason, a .coin element needs to take the whole area of the game to make sure its descendants will have access to the same area and we can easily place all the coins following the same code structure.

There is probably a way to make the game work by having the .coin elements as siblings but I didn’t focus on the HTML structure too much.

Calculating The Score

To calculate the score, I will add a last element that should also be nested within all the .coin elements. The nested configuration is mandatory here to be able to query all the .coin elements.

<div class="container">
  <div>
    <div class="game">
      <div class="mario"></div>
      <div class="coin">     
        <div class="coin">
          <div class="coin">
           <div class="result"></div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>Code language: HTML, XML (xml)

And here is the Sass code to illustrate how to calculate the score:

.result {
  $anim: ();
  @for $i from 1 to ($c+1) {
    --r#{$i}:0; 
    $anim: append($anim,'r#{$i} .1s forwards var(--s-r#{$i},paused)',comma);
    @container c#{$i} style(--c#{$i}-x: 1) and style(--c#{$i}-y: 1) {
       --s-r#{$i}: running
    }
    @keyframes r#{$i} {1%,to {--r#{$i}:1}}
  }
  $sum: ("var(--r1)");
  @for $i from 2 to ($c+1) {
    $sum: append($sum,'+ var(--r#{$i})' , space);
  }
  --sum: calc(#{$sum});
  animation: #{$anim};
}Code language: CSS (css)

For each coin, I will define one animation, one container query, and one @keyframe.

Notice how the configuration is similar to the one we used previously. When Mario touches the coin (--ci-x and --ci-y are equal to 1) we run an animation that will update the variable --ri from 0 to 1 and will maintain its value. In other words, a variable is incremented from 0 to 1 when a coin is touched and we have as many variables as coins in the game.

Then we define another variable that is the sum of all of them. That variable will contain the score of the game. To show the score we combine that variable with a counter and we rely on a pseudo-element like the below:

.result:before {
  content: "SCORE - " counter(r);
  counter-reset: r var(--sum);
}Code language: CSS (css)

Each time Mario collects a coin, the counter is reset with a new value, and the score is updated.

The Final Screen

To end the game, I will also rely on the sum variable and a style query. We test if the sum is equal to the number of coins. If that’s the case, we update some of the CSS to show the final screen.

The code will look like the below:

.result {
  container-name: r;
}
@container r style(--sum: #{$c}) {
  .result:after {
    /* the CSS of the final screen */
  }
}Code language: CSS (css)

For this style query, it’s important to register the sum variable using @property so that the browser can correctly evaluate its value and compare it with the number of coins.

@property --sum {
  syntax: '<integer>';
  inherits: true;
  initial-value: 0
}Code language: CSS (css)

We don’t need to do this with the other variables as there is no calculation to be done. They are either equal to 0 or 1.

What about the timer?

I deliberately skipped that part to make it your homework. The timer is closely related to the final screen and I let you dissect the original code to see how it works (when it starts, when it stops, etc). You will see that it’s the easiest part of the game. It’s also an opportunity to inspect the other parts of the code that I skipped.

We are done! Now, you know the secret behind my “Super CSS Mario” game. With a clever combination of scroll-driven animations and style queries, we can create a CSS-only game playable with keyboard navigation.

Take the time to digest what you have learned so far before moving to the next sections. I will share with you two more games but I will get faster with the explanation since the techniques used are almost the same. If you are struggling with some of the concepts, give it another read.

Super CSS Mario II

Let’s update the previous game and increase its difficulty by adding some enemies. In addition to collecting the coins, you need to also avoid the Goombas. Play “Super CSS Mario II”

Adding enemies to the game may sound tricky but it’s pretty easy since touching them will simply stop the game. The enemies will share the same code structure as the coins. The only difference is that all of them will control one variable. If one enemy is touched, the variable is updated from 0 to 1 and the game ends.

The HTML code looks like below:

<div class="container">
  <div>
    <div class="game">
      <div class="mario"></div>
      <div class="coin">     
        <div class="coin">
          <div class="coin">
            <div class="enemy">
              <div class="enemy">
                <div class="result"></div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>Code language: HTML, XML (xml)

Like the coins, I need to keep the nested structure to have the parent-child relation.

For the CSS code, I will have the following for the .result element.

.result {
  $anim: append($anim,'enemy .1s forwards var(--eeee,paused)',comma);
  --ee: 0;
  @for $i from 1 to ($e+1) {
    @container e#{$i} style(--e#{$i}-x: 1) and style(--e#{$i}-y: 1) {
      --eeee: running
    }
  }
  @keyframes enemy {1%,to {--ee:1}}
}Code language: CSS (css)

In addition to the previous animations defined for each coin, we add an extra animation that will control the variable --ee. All the style queries of the enemies will update the same animation which means if one of them is touched the variable will be equal to 1.

Then, for the final screen, we will have two conditions. Either the sum reaches the number of coins and you win or the enemy variable is equal to 1 and it’s a game over!

.result {
  container-name: r;
}
@container r style(--sum: #{$c}) {
  .result:after {
    /* you win */
  }
}
@container r style(--ee: 1) {
  .result:after {
    /* game over */
  }
}Code language: CSS (css)

Here is the Pen to see the full Sass code.

A CSS-only Maze game

One more game? Let’s go! This time it’s a maze game where the character needs to grab an object without touching the wall of the maze. Click to play the maze game.

The cool part about this game is that we have discrete movements, unlike the previous ones. It makes the game more realistic and similar to those retro games we enjoyed playing. The wall and the Dino are similar to the enemies and the coins of the previous game so I won’t detail them. I will focus on the movement and let you dissect the code of the other parts alone (here is the Pen).

Let’s start with the following demo:

Press the bottom arrow key to scroll and you will notice that the value will increment by a specific amount (it’s equal to 40 for me). If you keep tapping a lot of times, the value will keep increasing by the same amount.

This demonstrates that one click will always move the scroll by the same amount (as long as you don’t keep the key pressed). This information is what I need to create the discrete movement. If the game didn’t work well for you then it’s probably related to that value. In the Pen, you can update that value to match the one you get from the previous demo.

Now let’s suppose we want a maze with 10 columns and 5 rows. It means that we need 9 clicks to reach the last column and 4 clicks to reach the last row. The horizontal overflow needs to be equal to 360px=(40px*9) while the vertical overflow needs to be equal to 160px=(40px*4).

Let’s turn this into a code:

<div class="container">
  <div></div>
</div>Code language: HTML, XML (xml)
.container {
  width: 500px;  /* 50px * 10 */
  height: 250px; /* 50px * 5  */
}
.container div {
  width:  calc(100% + 40px*9);
  height: calc(100% + 40px*4);
}Code language: CSS (css)

The 50px I am using is an arbitrary value that will control the size of the grid.

Try to scroll the container using the keyboard and you will notice that you need exactly 9 clicks horizontally and 4 clicks vertically to scroll the whole content.

Then we can follow the same logic as the Mario game (the sticky container, the character, etc) but with a small difference: the x and y animations will animate integer variables instead of the top and left properties.

@property --x {
  syntax: '<integer>';
  inherits: true;
  initial-value: 0
}
@property --y {
  syntax: '<integer>';
  inherits: true;
  initial-value: 0
}
.character {
  width: 50px; /* the same value used to control the size of the grid */
  position: absolute;
  translate: calc(var(--x)*100%) calc(var(--y)*100%);
  animation: x linear,y linear;
  animation-timeline: scroll(nearest inline),scroll(nearest block);
}
@keyframes x { to { --x: 9 } }
@keyframes y { to { --y: 4 } }Code language: CSS (css)

We have a discrete keyboard movement using only CSS! Not only that, but thanks to the variable --x and --y we can know where our character is located within the grid.

You know the rest of the story, we apply style queries on those variables to know if the character hit a wall or if it reaches the Dino! I let you dissect the code as a small exercise and why not update it to create your own maze version? It could be a fun exercise to practice what we have learned together. Fork it and share your own maze version in the comment section.

Conclusion

I hope you enjoyed this CSS experimentation. It’s OK if you were a bit lost at times and didn’t fully understand all the tricks. What you need to remember is that scroll-driven animations allow us to link the scrolling progress to any kind of animation and style queries allow us to conditionally apply any kind of CSS based on the value of custom properties (CSS variables). Everything else depends on your creativity. I was able to create “Super CSS Mario” and a maze game but I am pretty sure you could do even better.

One day, someone will create a fully playable FPS using only CSS. Keyboard to move the character and mouse to kill enemies. Why not, nothing is impossible using CSS!

Wanna learn CSS from a course?

One response to “How Keyboard Navigation Works in a CSS Game”

  1. Chris Coyier says:

    On that very last paragraph, gotta see Adam’s Pen! https://codepen.io/cobra_winfrey/pen/oNOMRav

Leave a Reply

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

Did you know?

Frontend Masters Donates to open source projects. $313,806 contributed to date.