A CSS-only, no JS, no checkbox, swipe-aware (scroll-aware) game. With config options and music!
Intro
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.
✨ 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
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:
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.
📙: 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 notanimation-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.
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.
- Purpose: we are not scrolling to animate the content but to detect the swipe. The DOM stays fixed, only
--x
and--y
values update. - 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.
💡: [ 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:
- 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)
:scss --vehicle-on-cell-4: 1;
- 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;
- 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:
· 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)
$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:
📙: 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.
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;
}
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;
}
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.
html,
body {
overscroll-behavior: contain;
}
· 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;
}
· 2.2.c.: Blocking pinch-zoom
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
.view {
touch-action: pan-x pan-y;
}
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.
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;
}
}
· 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);
4. Kudos
I'd like to thank a few developers who indirectly contributed to this project by providing such quality educational content.
- Kudos to Bramus
- for all the great tutorials on scroll-driven animations.
- for the clean bi-directional scroll-driven animation setup.
- 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
- for raising the quality bar on CSS-only games.
- Kudos to Amy Kapernick
- for spreading the word about HTML state storing hack with
:target
- Car color and configuration options were stored in
:target
- Car color and configuration options were stored in
- for spreading the word about HTML state storing hack with
- 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. 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 */
- 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
- AI-generated obstacles — AI Art Generator
- AI background removal — remove.bg
- AI image to pixel art conversion — Pixelied
- Sprite sheet generation - Pixlr Express
- Vehicle — by warkentien2
- AI-generated vehicle — Recraft
- AI background removal — remove.bg
- AI image to pixel art conversion — Pixelied
- Drawing details and cropping — PixilArt
- Comping and enhancing assets — Pixlr Express
- Color variations and sprite sheet generation — Pixlr Express
- Dust adapted from Esteban Díaz's Youtube channel
- Image Landscape — by warkentien2
- AI-generated scenery — Recraft
- First sketch
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