CSS Quest - How to build an RPG in pure HTML/CSS

The above video is an abridged playthrough of CSS Quest, my fully playable RPG built with just HTML and CSS - no JavaScript. You can move the Yogaraptor character using the arrow keys, pick up and use items, use doors to move between different maps, solve puzzles and encounter enemies. All without a single line of JS. This first part of this article will outline the techniques used for the core mechanics of the game’s engine, with links to documentation for each so you can read up on their wider application (in case you’re not spending all your time building web-based dinosaur RPGs). A future article will cover the smaller details and polish of the game.

If you want to play before reading on, the game lives here. You’ll need to open it in Chrome or another blink-based browser, since some of the standard features it relies upon aren’t yet implemented in other browser engines.

Book 1: The engine

Chapter 1: Be there and be square - the grid

Let’s start with the movement mechanic. The maps in CSS Quest are tile-based and the game allows you to move the character a tile at a time, using the arrow keys or mouse scrolling. The character always sits in the middle of the current tile, never on a boundary, just like in the Pokémon or Zelda games from my (somewhat receded) youth.

In fact, you aren’t moving the character, you’re scrolling the map. Consider the following grid setup:

html
<div class="map">
  <div>1</div>
  <div>2</div>
  <div>3</div>
  <div>4</div>
  <div>5</div>
  <!-- and so on, up to 27 tiles -->
</div>
css
:root {
  --map-size: 6;
  --tile-size: 50px;
  --viewport-size: calc(3 * var(--tile-size));
}

.map {
  display: grid;
  grid-template-columns: repeat(var(--map-size), var(--tile-size));
  grid-template-rows: repeat(var(--map-size), var(--tile-size));
  gap: 0;
  width: var(--viewport-size);
  height: var(--viewport-size);
  overflow: auto;
  scroll-snap-type: both mandatory;

  > :nth-child(odd) {
    background-color: grey;
  }
}

Grid demo codepen

If we absolutely position a character over the top of this grid, as we scroll it will look like the character is moving between tiles.

css
.player {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translateX(-50%) translateY(-50%);
  border-radius: 100%;
  background: blue;
}

Grid with player demo codepen

Chapter 2: Crash, bang, wallop! Collision detection

We can’t really make a functional game unless we can detect which tile the character is “on”. By using a scroll-state container query we can know which tile is currently “snapped”. Since the character is always positioned over the center tile, we can combine this with scroll-snap-align: center on the tile elements to target the tile that the character is currently “on” and change its colour (we’ll do something useful with this later):

html
<div class="map">
  <div class="tile"><p>1</p></div>
  <div class="tile"><p>2</p></div>
  <div class="tile"><p>3</p></div>
  <div class="tile"><p>4</p></div>
  <div class="tile"><p>5</p></div>
  <!-- and so on, up to 27 tiles -->
</div>
css
.tile {
  scroll-snap-align: center center;
  container-type: scroll-state;

  p {
    display: grid;
    width: 100%;
    height: 100%;
    place-content: center;
  }
}

@container scroll-state(snapped: x) or scroll-state(snapped: y) {
  .tile p {
    background-color: goldenrod;
  }
}

Collision detection demo codepen

A couple notes here before moving on:

Chapter 3: Go! Walk out the door!

An RPG where you can only explore one area would be a little boring. Caves need to be explored, forests navigated, and if you can’t mosey into someone else’s house uninvited and take their personal items, what are we even doing?

So we need, in essence, doors: tiles that allow triggering movement between maps. This was the most challenging - and the most rewarding - part of building this game engine.

My first approach was perhaps a little ambitious - to allow walking onto door tiles and automatically switching maps. Using the snapped: true container queries mentioned above, detecting when the player moved onto a door tile was solved. But container queries can only be used to target children of the container, so this would have involved making all the tiles for the map a door linked to a child of the door tile itself. OK for one-way navigation, but how would you return through the same door to the previous map?

My second approach was to use anchor links and CSS’s :target psuedo-class. The idea was simple - we detect when you’ve moved onto a door, show a popover with a link to a hash fragment matching the id of the map the door is linked to:

html
<div class="map" id="overworld">
  <!-- ...other tiles -->
  <div class="tile door">
    <a class="button" href="#cave">Enter the caves</a>
  </div>
</div>

<div class="map" id="cave">
  <!-- ...tiles -->
</div>
css
.tile.door a {
  display: none;
}

@container scroll-state(snapped: x) or scroll-state(snapped: y) {
  .tile.door a {
    display: block;
  }
}

.map {
  display: none;

  &:target {
    display: block;
  }
}

This works, and we can even choose the tile the player lands on in the new map by putting the id on that tile within the target map, and tweaking the css (more on :has() in the next chapter):

css
.map {
  display: none;

  &:has(.tile:target) {
    display: block;
  }
}

Because hash links scroll the page to the target element, making a specific tile the link’s target causes the map to scroll that tile into the center, and therefore be the tile on which the player is positioned.

The initial map (and starting tile within) can be shown by linking from a start screen in the same manner, or we can just give the initial map special treatment in CSS - always shown, but with a lower z-index than other maps.

Doors with :target demo codepen

It’s a solid mechanic, and even supports bidirectional movement; you can return to the previous map via another door tile with no extra code. However, it broke one feature that I felt contributed a large part of the game’s “wait, how is this possible??” effect: arrow key movement. When you switch to a new map, it doesn’t gain keyboard focus - you have to know to click into the map first.

You can use mousewheel, touchpad or even touchscreen to move the player around the screen, but it feels clunky to use, allows diagonal movement and generally doesn’t look “right”.

So, third time lucky? Enter HTML dialogs. This element is the web platform’s native answer to the “modal” pattern with which we are all familiar: a popup within the page that shows extra information or allows further actions that are deemed secondary, or don’t fit into the main page layout.

<dialog> elements receive keyboard focus automatically when opened - or rather, the first focusable element within the dialog receives focus. But how to open one without JS? As it turns out, HTML has the amnswer: the command/commandfor attributes. Add command="show-modal" commandfor="my-modal" to any button element to declaritively designate it as the trigger for opening <dialog id="my-modal" />.

Now when a player enters a door tile we show them a popover with a button inside, linked to the dialog containing the target map:

html
<div class="map" id="overworld">
  <!-- ...other tiles -->
  <div class="tile door">
    <button command="show-modal" commandfor="cave">Enter the caves</a>
  </div>
</div>

<div class="map" id="cave">
  <!-- ...tiles -->
</div>
css
.tile.door button {
  display: none;
}

@container scroll-state(snapped: x) or scroll-state(snapped: y) {
  .tile.door button {
    display: block;
  }
}

And returning to the previous map? command supports a close value which dismisses the target dialog:

html
<div class="map" id="overworld">
  <!-- ...other tiles -->
  <div class="tile door">
    <button command="show-modal" commandfor="cave">Enter the caves</a>
  </div>
</div>

<div class="map" id="cave">
  <!-- ...tiles -->
  <div class="tile door">Go back outside</div>
</div>

I said this approach allowed us to keep keyboard focus without any extra mouse clicks. This is because when a dialog is opened via a button with command (or with JS, via HTMLDialogElement.showModal()), the browser gives keybaord focus to that dialog’s first focusable child. By making the target tile focusable and setting autofocus we both preserve keyboard focus and set it as the tile upon which the player should arrive in the new map:

html
<div class="map" id="cave">
  <-- ...other tiles -->
  <div class="tile" tabindex="0" autofocus></div>
</div>

As with :target, the browser scrolls the container to the focused tile, and scroll-snap-align: center takes care of making sure that the player is positioned over it.

Put together, approach three looks like this:

Doors with <dialog> demo codepen

Chapter 4: Picking up the pace - items

One of the key parts of any RPG is your inventory - the items you collect and use along your adventure. Implementing this implies some sort of state. Without JS, we can achieve this using checkboxes and the relatively modern :has() pseudo-class:

html
<div class="game">
  <ul class="inventory">
    <li></li>
    <li></li>
    <li></li>
  </ul>

  <div class="tile item">
    💎
    <input type="checkbox" id="treasure" />
    <label for="treasure">Pick up 💎</label>
  </div>
</div>
css
.game:has(#treasure:checked) .inventory > :nth-child(1) {
  content: "💎";
}

.tile.item input,
.tile.item label {
  opacity: 0;
  pointer-events: none;
}

@container scroll-state(snapped: x) or scroll-state(snapped: y) {
  .tile.item label {
    opacity: 1;
    pointer-events: unset;
  }
}

Items demo codepen

Later in the game we can infer which items the player has by using the same :has() trick; for example, only allowing movement through a particular door if the player has picked up a key. Game state, in pure CSS!

Book 2: Polish and juice

I’m still writing this part of the article, but come back soon to find out how we:

Epilogue

Why take the trouble to build a game in such a strange way?

Firstly, I enjoy working within constraints. My undergraduate degree was in English Literature, and I took several modules of Creative Writing courses, part of which involved writing poetry that fit certain forms - Haiku, Villanelle, etc. Making things deliberately harder for yourself can force you to learn things you otherwise never would have - I know more now about meter in verse than I would have if I’d stuck to writing free-form poetry, and it’s enhanced my enjoyment of reading others’ poetry too. If I’d allowed myself to use JS, it would have been very tempting to reach for it whenever I hit a roadblock. Most of the HTML and CSS features outlined above were new to me, and I would have missed out on learning them had I not enforced my no-JS rule.

Secondly, I wanted to see what the web platform was now capable of. The appendix below outlines how each of the features used to build the game can be used on more traditional websites. I think you’ll agree the toolkit we have at our disposal, while far from perfect, is a huge step beyond what we had even a few years ago. For me, who started my web development journey building table-based websites in the early noughties, the difference is mind-blowing.

As someone who entered web development at a time when the dust from the browser wars had barely begun to settle, it’s heartening to see standards not only prevail, but prove that when everyone comes together to improve not only user but also developer experience, amazing things can happen. Even the salvation of the dinosaurs.