DEV Community

Cover image for Desert Racer 🏜️: World's First CSS-only Swipe-Aware Game!
Philip Warkentien II
Philip Warkentien II

Posted on

Desert Racer 🏜️: World's First CSS-only Swipe-Aware Game!

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


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.

Screenshot of Gemini, see caption below


👨 — "Can I achieve bi-directional swipe detection with only CSS and HTML?"
🤖 — "No, achieving bi-directional swipe detection with CSS only and HTML is not possible. CSS lacks the functionalities needed to determine the direction and intent of a swipe gesture."

✨ Creativity is not yet obsolete! ✨


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
  2. Building a swipe-aware game
    1. Primary obstacles (idea validation)
    2. Fine-tuning the mobile UX
    3. Secondary obstacles
  3. Lessons learned
  4. Kudos
  5. Credits
  6. FAQ

1. The idea

On October 6th, 2023, I caught wind of a brand-new CSS feature: scroll-driven animations. All thanks to Bramus' excellent expository article. Later that night I WhatsApped myself the following idea:

WhatsApp screenshot dated October, 6th, 2023. See caption below

👨 — "X and Y scroll-driven animations with snap-to-center when released to emulate swipe detection!!!! Hide the scrollbar with CSS."

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.


Click to play Desert Racer


📙: Original CSS background, assets generated with AI, music from Pixabay.

📙: 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).

2. Building a swipe-aware game

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

⚠️ Keep in mind that at the time I created this game I wasn't aware of the space toggle hack. 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.

Are we swiping or scrolling?

Under the hood, we are using CSS scroll properties and the experimental 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 Mouse input config


2.1.: Primary obstacles (validating ideas)

· 2.1.a.: Achieving bi-directional scroll-driven animation

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 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

💡: [ Previous Art ] Adam Argyle also mixed scroll animation + scroll snap to mimic a mobile's "refresh page" swipe interaction. Article 👏

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 ⚙️ menu to play with different swipe-aware configurations.

📙: Settings menu was also written in pure CSS, because "why not?"

· 2.1.b.: Detecting collision

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. --cell-pattern-n represents --vehicle-on-cell-n E.g.: if our swipe coordinates are (-1, 0):
   --vehicle-on-cell-4: 1;
Enter fullscreen mode Exit fullscreen mode
  1. Place obstacles on the 3x3 grid. E.g.: for a tree on the left side of the road:
   --obstacle-on-cell-1: 1;
   --obstacle-on-cell-4: 1;
   --obstacle-on-cell-7: 1;
Enter fullscreen mode Exit fullscreen mode
  1. Detect collision if the current cell also has an obstacle.
   --collision-on-cell-4: calc(
     var(--vehicle-on-cell-4) * var(--obstacle-on-cell-4)
Enter fullscreen mode Exit fullscreen mode

You can see this logic in motion on .gif below:

· 2.1.c.: Animating a collision map over time

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

To simplify my work, I declaratively generated the animation. (With .SCSS)

  (("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)),
Enter fullscreen mode Exit fullscreen mode

Each item represents a keyframe.

You can see obstacles animating across time on the .gif below:
obstacle depth

📙: blue overlay for obstacles, red overlay for collisions

📙: Transparent floor lets you see how I planned subterranean obstacles to block under passage

· 2.1.d.: Immediately stopping the game after any collision

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!

Now the tricky part.

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%).

So what did I do?

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

Here's the code:

: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;
Enter fullscreen mode Exit fullscreen mode

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.

· 2.1.e.: Detecting victory

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.

@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;
Enter fullscreen mode Exit fullscreen mode

2.2.: Fine-tuning a swipe-first mobile UX

· 2.2.a.: Disabling native swipe-navigation

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:

The contain value disables native browser navigation, including the vertical pull-to-refresh gesture and horizontal swipe navigation.

body {
  overscroll-behavior: contain;
Enter fullscreen mode Exit fullscreen mode

· 2.2.b.: Fixing vertical swipe layout shift

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

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

.game-view {
  position: absolute;
  height: 100svh;
  bottom: 0;
Enter fullscreen mode Exit fullscreen mode

· 2.2.c.: Blocking pinch-zoom

  content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
Enter fullscreen mode Exit fullscreen mode
.view {
  touch-action: pan-x pan-y;
Enter fullscreen mode Exit fullscreen mode

2.3.: Secondary obstacles

· 2.3.a.: How to handle huge CSS files

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.

  ) {
  --car-color: 1;

  .dynamic-link:nth-of-type(1) {
    display: inline-block;
Enter fullscreen mode Exit fullscreen mode

· 2.3.b.: How to animate as much as 126 obstacles in a single phase in 3D space

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)

· 2.3.c.: How to auto-play sounds

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.

· 2.3.a.: How to persist state between page changes

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

It's incredible how much we can create with the current state of CSS math and CSS logic. 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.

width: calc(var(--complex-logic) * 1vw);
Enter fullscreen mode Exit fullscreen mode

4. Kudos

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

  1. Kudos to Bramus
    • for all the great tutorials on scroll-driven animations.
    • for the clean bi-directional scroll-driven animation setup.
  2. Kudos to Jamie Coulter
    • 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
    • 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
  • for spreading the word about named grid lines.

    • The home page's highly dynamic Bento Style Grid wouldn't be possible without it!
           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.
       .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 */
  1. Kudos to LEGO® Friends Heartlake Rush
    • for the UI and gameplay inspiration!

5. Credits

Assets and UI

  • Ground and Sky — CSS art — by warkentien2
  • Irregular road — SVG designed in Figma — by warkentien2
  • Obstacles — by warkentien2
  • Vehicle — by warkentien2
  • Dust adapted from Esteban Díaz's Youtube channel
  • Image Landscape — by warkentien2
  • First sketch Desert Racer UI mockup on paper

Sound snippets and soundtrack

  • Home — revving — by warkentien2
  • All phases — driving noises — by warkentien2
  • Phase 1 — Dark Country Rock — by moodmode
  • Phase 2 — Western Cowboy — by Music_For_Videos
  • Phase 3 — Tumbleweed Tango — by moodmode
  • Phase 4 — Excess Voltage — by moodmode
  • Phase 5 — Spirit of the Road — by SergePavkinMusic
  • Phase X — Cowboy Redemption — by Music_Unlimited

6. FAQ

Go to Desert Racer's F.A.Q. section at the bottom of the homepage.


Thank you for reading!

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

Follow me on X, @warkentien2

Top comments (10)

grahamthedev profile image

Great article and some interesting tricks in there! Love it. 💗

edememediong1 profile image
Emediong Bassey

Bro just entered god-mode

warkentien2 profile image
Philip Warkentien II

😂 Thanks!

Speaking of "God mode" there's a hidden phase in the game that only by using a trick hinted on phase 4 can you beat it.

vikkrantxx7 profile image
Vikrant Sharma

Great ideas and skills.

diegochiola profile image

so good!

algorodev profile image
Alex Gonzalez

Congratulations for achieving god mode!

edwineinsen profile image
Einsen Vásquez

Amazing work! Like you say creativity and skills are not yet obsolete! And AI can wait for a time to reach us in that aspect.

swatibadola profile image
Swati Badola

Interesting. Learned something new. Surely trying it!!❤️🙌

gami13 profile image
Marcin Czechowicz
warkentien2 profile image
Philip Warkentien II

Similar because it has a car and a road? Then we are both "ripping off" Sega Turbo 1981.

Now if you are wondering if these projects are similar in mechanics, they are not.
Alvaro is showcasing a simple position-aware trick by replacing the cursor with a car, and is checking for :hover. This game is swipe-aware, state-aware, can be played on mobile, and doesn't rely on :hover at all – there was no out-of-the-box CSS pseudo-class I could simply use to detect collisions.