Desert Racer 🏜️: World's First CSS-only Swipe-Aware Game!
Tags: webdev, css, frontend, html
Published: 2024-03-27 13:20:00

#### A CSS-only, no JS, no checkbox, **swipe-aware** (scroll-aware) game. With config options and music!

---

## Intro

<br>

I built Desert Racer **to showcase** the unique and unorthodox tricks of **CSS-only Swipe Awareness** and **CSS-only Collision Detection.** I believe these tricks to be **the first of their kind.** You're welcome to challenge these claims. This article covers the aforementioned tricks, plus the overall process of building a Swipe-Aware game.

Checked again today – March 27th, 2024 – and **Gemini (Google's new AI)** still believes swipe awareness to be impossible.

<figure>
  <img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/34j59uk4af3ahfs6jsgj.png" alt="Screenshot of Gemini, see caption below">
  <figcaption>transcription:<br>
  👨 — "Can I achieve bi-directional swipe detection with only CSS and HTML?"<br>🤖 — <b>"No,</b> achieving bi-directional swipe detection with CSS only and HTML <b>is not possible.</b> CSS lacks the functionalities needed to determine the direction and intent of a swipe gesture."</figcaption>
</figure>

> <small>_:sparkles: Creativity is not yet obsolete! :sparkles:_</small>

&nbsp;

Is CSS the right tool for this job? Not at this moment. However... CSS has been offloading JavaScript event handlers. Hopefully, this article contributes to that goal. Either way, as somewhat of an artist myself, building things with the wrong tool – for the sake of debunking something considered impossible – is a natural impulse. I had a hunch that it would work, and it did.

This article will be **interactive** and hopefully inspiring. So, stick around! _(Or at least scroll to see the **cool GIFs**)_

---

## Table of contents

1. [The idea](#the-idea)
2. [Building a swipe-aware game](#how-did-i-build-this)
  1.  [Primary obstacles (idea validation)](#2-primary-obstacles)
  2.  [Fine-tuning the mobile UX](#2-mobile-ux)
  3.  [Secondary obstacles](#2-secondary-obstacles)
3. [Lessons learned](#lessons-learned)
4. [Kudos](#kudos)
5. [Credits](#credits)
6. [FAQ](#faq)

---

## 1. The idea <a name="the-idea"></a>

On October 6th, 2023, I caught wind of a brand-new CSS feature: **scroll-driven animations**. All thanks to [Bramus'](https://twitter.com/bramus) excellent expository [article](https://scroll-driven-animations.style/). Later that night I WhatsApped myself the following idea:

<figure>
  <img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fjkdm65f92mh0u74aysi.png" alt="WhatsApp screenshot dated October, 6th, 2023. See caption below">
  <figcaption>👨 — "X and Y scroll-driven animations with snap-to-center when released to emulate swipe detection!!!! Hide the scrollbar with CSS."</figcaption>
</figure>

&nbsp;
You can feel the excitement with all the typos, the misplaced comma, missing article, and missing preposition. **Could I achieve swipe awareness without JavaScript?** This idea grew into this HTML and CSS-only game you see today. Go test-drive, then come back to see how I built it.

&nbsp;

[![Click to play Desert Racer](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wraw4epyxe1f0vc4bi7i.png)](https://000699350.deployed.codepen.website/)

&nbsp;

> <small>_:orange_book:: Original CSS background, assets generated with AI, music from [Pixabay](https://pixabay.com/)._</small>
>
> <small>_:orange_book:: [scroll-timeline](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-timeline) is only supported by Blink/Chromium browsers (Chrome Desktop, Edge Desktop, and Chrome for Android). For iPhone users: Chrome for iOS is just Safari with a skin, so use Chrome on your MacBook (that we all know you have)._</small>

---

## 2. Building a swipe-aware game <a name="how-did-i-build-this"></a>

This process demanded multiple prototype validations, adapting, refactoring, and inventing never-before-seen CSS tricks (which can lead you down an exploration rabbit hole).

> <small>_:warning: Keep in mind that at the time I created this game I wasn't aware of the [space toggle hack](https://codepen.io/2kool2/pen/abjdZzJ/1000). So **the entire logic** of this game **relies on CSS** properties that accept **numerical values.** For instance.: I could manipulate `animation-duration` but not `animation-play-state`._</small>

### Are we swiping or scrolling?

Under the hood, we are using CSS scroll properties and the experimental [scroll-timeline 🧪](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-timeline). However, for touch devices and trackpads, the real-world action is literally to swipe, hence the name **swipe-detection.** I made this as a swipe-first game with a Mouse-wheel-detection fallback. If your mouse supports side-scrolling, remember to activate the mouse input setting on Desert Racer's home screen.
![Mouse](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tba8owymc6dxoxhsmq2o.png) ![Mouse input config](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vhenbxd6noqlhe4famez.png)

&nbsp;

### 2.1.: Primary obstacles (validating ideas) <a name="2-primary-obstacles"></a>

#### <big>· 2.1.a.:</big> Achieving bi-directional scroll-driven animation <a name="1-a"></a>

The first thing I needed to validate was if I could control my timeline by scrolling on both axes. At first, I got misled by the [scroll-timeline-axis](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-timeline-axis) property definition, since accepted values were only unidirectional (`x`, `y`, `block`, and `inline`). To bypass this I nested two scrollable containers to handle each axis. This is still the solution used for mobile, as it limits motion and avoids accidental horizontal motion while swiping up and down.

Over halfway through the project, I ran into **Bramus'** clever single-element bi-directional solution: comma-separated scroll timelines! It's obvious after you see it. I don't automatically think a new CSS property has comma support, so it didn't cross my mind.

##### Snapping back to center

What differentiates this technique from a normal scroll-driven animation is two-fold: Purpose and Repeatability.

1. Purpose: we are not scrolling to animate the content but to detect the swipe. The DOM stays fixed, only `--x` and `--y` values update.
2. Repeatability: by snapping back to center, we can repeat our action as much as we need. We wouldn't want a scroll box that always restarts, but we do want to be able to swipe again.

![Basic structure for swipe aware CSS](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1o44lgt6txnv6vep3bdl.gif)

> <small>_:bulb:: **[ Previous Art ]** [Adam Argyle](https://twitter.com/argyleink) also mixed scroll animation + scroll snap to mimic a mobile's "refresh page" swipe interaction. [Article](https://nerdy.dev/pull-to-refresh-prototype-with-scroll-snap-and-scroll-driven-animation) :clap:_</small>

##### Main trick

We use two separate scroll timelines to control changes in the horizontal and vertical axes. For the scroll-timelines to activate we need the content to be larger than the scroll wrapper – as you would expect. Depending on the goal of your game we can have any size grid (3x3 recommended). We can also decide on snapping or not snapping back to the center. For Desert Racer the jump axis (y-axis) snapped back to the ground level, but changing lanes didn't trigger a snap action. You can also use Houdini's @property declaration to achieve interpolated values between 0 and 1, thus making the swipe detectable to the smallest movement. With this, you can create motions such as drawing circles.

Use the **settings :gear: menu** to play with different swipe-aware configurations.

{% codepen https://codepen.io/warkentien2/pen/JjVYjNK %}

> <small>_:orange_book:: Settings menu was also written in pure CSS, because "why not?"_</small>

#### <big>· 2.1.b.:</big> Detecting collision <a name="1-b"></a>

To detect collision I check if the vehicle's current cell is also an obstacle cell.
`--collision-on-cell-4: calc(var(--vehicle-on-cell-4) * var(--obstacle-on-cell-4))`

Broken into steps:

1. Detect what cell the vehicle is on by converting `--x` and `--y` swipe coordinates into their corresponding **current** grid cell.
   {% codepen https://codepen.io/warkentien2/pen/LYvZZWo %}
   `--cell-pattern-n` represents `--vehicle-on-cell-n`
   **E.g.:** if our swipe coordinates are `(-1, 0)`:
   ```scss
   --vehicle-on-cell-4: 1;
   ```
2. Place obstacles on the 3x3 grid.
   **E.g.:** for a tree on the left side of the road:
   ```scss
   --obstacle-on-cell-1: 1;
   --obstacle-on-cell-4: 1;
   --obstacle-on-cell-7: 1;
   ```
3. Detect collision if the **current** cell also has an obstacle.
   ```scss
   --collision-on-cell-4: calc(
     var(--vehicle-on-cell-4) * var(--obstacle-on-cell-4)
   );
   ```

_You can see this logic in motion on .gif below:_
![Collisions](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3vgisz702a24hj9kjpsq.gif)

#### <big>· 2.1.c.:</big> Animating a collision map over time <a name="1-c"></a>

Animate the value of `--obstacle-on-cell-k`, where `1 ≤ k ≤ 9`.

To simplify my work, I declaratively generated the animation. <small>(With .SCSS)</small>

```scss
$OBSTACLES: {
  (),
  (),
  (("tree", 1), ("tree-arch", 3)),
  (("tree-arch", 1), ("tree", 2), ("tree-arch", 3)),
  (),
  (),
  (("arch", 1), ("arch", 3)),
  (("rock", 1), ("rock", 3)),
  (("arch", 1), ("rock", 2), ("arch", 3)),
  (),
  (),
}
```

Each item represents a keyframe.

_You can see obstacles animating across time on the .gif below:_
![obstacle depth](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hhzl1xlejnmbn764d146.gif)

> <small>_:orange_book:: **blue** overlay for obstacles, **red** overlay for collisions_</small>
>
> <small>_:orange_book:: Transparent floor lets you see how I planned subterranean obstacles to block under passage_</small>

#### <big>· 2.1.d.:</big> Immediately stopping the game after any collision <a name="1-d"></a>

I check each cell for a possible collision and store those results on `--collision-on-cell-k`, where `1 ≤ k ≤ 9`.
If the sum of all possible collisions is greater than zero, we have a collision!

<big>Now the tricky part.</big>

As soon as the animation ticks to the next keyframe the collision drops. So, how do I keep the collision state? Remember that I can't control non-numerical CSS properties, so I can't simply set `animation-play-state: paused;`. By changing the duration to `animation-duration: calc(var(--virtually-infinite) * 1s);` I also change the progress of the current animation. (E.g.: If I'm at 50% and suddenly increase the animation duration by 10x, the total animation progress will drop to 5%).

<big>So what did I do?</big>

I immediately slid in the Game Over screen, and set the slide-out transition to 31.7 years! This means that, unless you plan on waiting it out, the Game Over state is perceivably static.

![game over](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/s4b0d1ag3iinsn957wsx.gif)

Here's the code:

```scss
:root {
  --virtually-infinite: 1000000000s; // 31.7 years
}

.game-over {
  background: black;
  bottom: calc(var(--zero-collisions) * 200lvh);
  transition: bottom calc(
      var(--zero-collisions) * var(--virtually-infinite) + 1ms
    ) linear;
  z-index: 100;
}
```

On the split second that `--zero-collisions` is 0, `bottom` is set to `0` and `transition-duration` to `1ms`. The trapdoor has fallen. `--zero-collisions` is once more set to 1, but it will take 31.7 to reset the trapdoor. If `--zero-collisions` is set to 0 in the background due to a secondary collision, we won't notice since the trapdoor is already down.

#### <big>· 2.1.e.:</big> Detecting victory <a name="1-e"></a>

This one was easy. I set `--you-win` to true at the end of the round. The victory screen slides up – behind any possible Game over screens – and stays up.

```scss
@keyframes move-obstacles {
  // ...

  99.999% {
    --you-win: 0;
  }
  100% {
    --you-win: 1; // last keyframe
  }
}

.victory {
  transition: opacity 250ms ease-out;
  bottom: calc((1 - var(--you-win)) * 200lvh);
  opacity: calc(0.875 * var(--you-win));
  z-index: 99;
}
```

### 2.2.: Fine-tuning a swipe-first mobile UX <a name="2-mobile-ux"></a>

#### <big>· 2.2.a.:</big> Disabling native swipe-navigation <a name="2-a"></a>

When you start creating a swipe-first mobile web experience, you quickly realize that the Browser already uses swipe-actions for things such as horizontal swipe native browser navigation, vertical pull-to-refresh gesture, toggling the address bar, and pinch-zooming.

Here's how we block it:

> <small>_The contain value disables native browser navigation, including the vertical pull-to-refresh gesture and horizontal swipe navigation._</small>

```scss
html,
body {
  overscroll-behavior: contain;
}
```

#### <big>· 2.2.b.:</big> Fixing vertical swipe layout shift <a name="2-b"></a>

Vertical swipe toggles address bar visibility, which resizes the UI. 
Solution: Tie layout mechanics to the bottom of the viewport.

```scss
.container {
  position: relative; // or absolute;
  height: 100lvh;
}

.game-view {
  position: absolute;
  height: 100svh;
  bottom: 0;
}
```

#### <big>· 2.2.c.:</big> Blocking pinch-zoom <a name="2-c"></a>

```html
<meta
  name="viewport"
  content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
```

```scss
.view {
  touch-action: pan-x pan-y;
}
```

### 2.3.: Secondary obstacles <a name="2-secondary-obstacles"></a>

#### <big>· 2.3.a.:</big> How to handle huge CSS files <a name="3-a"></a>

To load very large CSS files, I created an old-school MPA (multiple-page application). This is a fancy way of saying that I loaded a different CSS file per page, thus allowing me to add multiple phases to this game without bloating the CSS file. However, since I was hopping between pages I couldn't hold state with checkboxes. So I decided to hold state with the URL and the`:target` selector.

```scss
body:has(
    :is(
        #color-1:target,
        #color-1--lowres:target,
        #color-1--mouse:target,
        #color-1--lowres--mouse:target,
        #color-1--muted:target,
        #color-1--lowres--muted:target,
        #color-1--mouse--muted:target,
        #color-1--lowres--mouse--muted:target
      )
  ) {
  --car-color: 1;

  .dynamic-link:nth-of-type(1) {
    display: inline-block;
  }
}
```

#### <big>· 2.3.b.:</big> How to animate as much as 126 obstacles in a single phase in 3D space <a name="3-b"></a>

I don't, GPU doesn't handle animating 126 obstacles in 3D space, so it's just an illusion. All obstacles are hidden and fixed at a given distance, and only animate toward the screen on cue `animation-delay`. This way we don't have more than a couple dozen obstacles animating in any given time.

##### other tricks

`will-animate`, `contain: strict`, `backface-visibility: hidden` _(This goes beyond the scope of this article)_

#### <big>· 2.3.c.:</big> How to auto-play sounds <a name="3-c"></a>

It's as straightforward as placing `<audio autoplay>` elements in your HTML documents and hiding them with CSS.
For the **muted** option, use a separate HTML document without `<audio>` tags.

#### <big>· 2.3.a.:</big> How to persist state between page changes <a name="3-d"></a>

By storing the car color and game config state on the URL and reading it with the `:target` selector. However, for audio on and off I directly rendered a page with or without `<audio>` tags, since there is no way to turn audio on and off without JavaScript.

---

## 3. Lessons learned <a name="lessons-learned"></a>

It's incredible how much we can create with the current state of CSS math and CSS logic. [GrahamTheDev](https://twitter.com/GrahamTheDev) is here to prove it!

I'll leave a single **Lesson Learned:** Keep CSS variables unit-free until the very end, which is until you have to use it. I'm not the first person to say this, but it's worth the emphasis.

```scss
width: calc(var(--complex-logic) * 1vw);
```

---

## 4. Kudos <a name="kudos"></a>

I'd like to thank a few developers who indirectly contributed to this project by providing such quality educational content.

1. Kudos to [Bramus](https://twitter.com/bramus)
   - for all the great tutorials on scroll-driven animations.
   - for the clean bi-directional scroll-driven animation setup.
2. Kudos to [Jamie Coulter](https://twitter.com/jamiecoulter89)
   - for raising the quality bar on CSS-only games.
     - You so masterfully showcased the power of checkboxes, that I purposefully refrained from using them
3. Kudos to [Amy Kapernick](https://twitter.com/Amys_Kapers)
   - for spreading the word about HTML state storing hack with `:target`
     - Car color and configuration options were stored in `:target`
4. Kudos to [Kevin Powell](https://twitter.com/KevinJPowell)

   - for spreading the word about named grid lines.

     - The home page's highly dynamic Bento Style Grid wouldn't be possible without it!

       ```css
       /* 
           I pretty much only had to reset these two properties per media query. 
           7 grid sections styled across 11 @media definitions totaled 22 style declarations.
           The traditional grid-area approach could need as many as 77 style declarations.
           e.g.:
       */

       .bento-box {
         grid-template-columns: [header-start display-start] 4fr [display-end share-start actions-start specs-start] 3fr [header-end share-end config-start] 1fr [config-end actions-end specs-end];
         grid-template-rows: [header-start config-start] 1fr [header-end display-start share-start] 0.75fr [config-end share-end actions-start] 1fr [actions-end specs-start] 1.5fr [display-end specs-end];
       }

       @media screen and (max-width: 1300px) {
         .bento-box {
           grid-template-columns: [header-start config-start display-start] 3.25fr [config-end display-end actions-start specs-start share-start] 3.875fr [header-end display-end actions-end specs-end share-end];
           grid-template-rows: [header-start] 0.875fr [header-end config-start actions-start] 0.625fr [config-end display-start] 0.125fr [actions-end specs-start] 1.5fr [specs-end share-start] 1fr [display-end share-end];
         }
       }

       @media screen and (max-width: 1000px) {
         .bento-box {
           grid-template-columns: [header-start config-start display-start specs-start] 2.75fr [config-end display-end specs-end actions-start share-start] 1.5fr [header-end actions-end share-end];
           grid-template-rows: [header-start] 1.125fr [header-end config-start actions-start] 0.75fr [config-end display-start] 2fr [actions-end share-start] 2fr [display-end specs-start] 2.5fr [share-end specs-end];
         }
       }

       /* ... 8 other queries */
       ```

5. Kudos to **LEGO® Friends Heartlake Rush**
   - for the UI and gameplay inspiration!

---

## 5. Credits <a name="credits"></a>

### Assets and UI

- Ground and Sky — CSS art — by **warkentien2**
- Irregular road — SVG designed in [Figma](https://figma.com/) — by **warkentien2**
- Obstacles — by **warkentien2**
  - AI-generated obstacles — [AI Art Generator](https://www.aiartgenerator.cc/)
  - AI background removal — [remove.bg](https://www.remove.bg/upload)
  - AI image to pixel art conversion — [Pixelied](https://pixelied.com/)
  - Sprite sheet generation - [Pixlr Express](https://pixlr.com/)
- Vehicle — by **warkentien2**
  - AI-generated vehicle — [Recraft](https://app.recraft.ai/)
  - AI background removal — [remove.bg](https://www.remove.bg/upload)
  - AI image to pixel art conversion — [Pixelied](https://pixelied.com/)
  - Drawing details and cropping — [PixilArt](https://www.pixilart.com/draw)
  - Comping and enhancing assets — [Pixlr Express](https://pixlr.com/)
  - Color variations and sprite sheet generation — [Pixlr Express](https://pixlr.com/)
- Dust adapted from **Esteban Díaz's** [Youtube channel](https://www.youtube.com/watch?v=FdEX-4lZsnk)
- Image Landscape — by **warkentien2**
  - AI-generated scenery — [Recraft](https://app.recraft.ai/)
- First sketch
  ![Desert Racer UI mockup on paper](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d1j889c30w7s8gjg47ot.png)

### Sound snippets and soundtrack

- Home — revving — by **warkentien2**
- All phases — driving noises — by **warkentien2**
- Phase 1 — Dark Country Rock — by [moodmode](https://pixabay.com/users/moodmode-33139253/)
- Phase 2 — Western Cowboy — by [Music_For_Videos](https://pixabay.com/users/music_for_videos-26992513/)
- Phase 3 — Tumbleweed Tango — by [moodmode](https://pixabay.com/users/moodmode-33139253/)
- Phase 4 — Excess Voltage — by [moodmode](https://pixabay.com/users/moodmode-33139253/)
- Phase 5 — Spirit of the Road — by [SergePavkinMusic](https://pixabay.com/users/sergepavkinmusic-6130722/)
- Phase X — Cowboy Redemption — by [Music_Unlimited](https://pixabay.com/users/music_unlimited-27600023/)

---

## 6. FAQ <a name="faq"></a>

Go to [Desert Racer's F.A.Q.](https://000699350.deployed.codepen.website/) section at the bottom of the homepage.

&nbsp;
&nbsp;

<big>Thank you for reading!</big>

You're welcome to ask questions and speak your mind.

Follow me on **X**, [@warkentien2](https://twitter.com/warkentien2)