A super simple "rhythm" game (with no sound) built entirely out of HTML and CSS (no Javascript at all!).
Important note: This game works best on mobiles or by having dev tools open with mobile emulation turned on. This is because click events on moving targets are treated very differently to touch events and make the game pretty much unplayable.
I've been building a series of these silly experiments to explore what is possible using only CSS on a page.
It's a fun experiment, trying to build features into something when you have a ton of restrictions. It forced me to learn a little about CSS, but mostly, this is just for fun, to show that it's possible.
This experiment is actually very simple, even compared to some of my older ones.
The menus work similarly to CSS and Binary Decoder experiments. There are a set of radio buttons representing the current "state".
<input type="radio" name="game-state" id="start" checked>
<input type="radio" name="game-state" id="playing-easy">
<input type="radio" name="game-state" id="playing-med">
<input type="radio" name="game-state" id="playing-hard">
Only one can be selected at a time, and we use these to decide which screen to show
.startScreen, .playingScreen, .gameOverScreen {
display: none;
}
#start:checked ~ #game .startScreen {
display: flex;
}
#playing-easy:checked ~ #game .playingScreen {
display: block;
// The delay time here changes depending on if easy, medium or hard mode
animation: hide 0.1s linear 45s forwards;
}
#playing-easy:checked ~ #game .gameOverScreen {
display: flex;
opacity: 0;
// Delay showing the gameover screen until the same amount of time as the play time above
animation: show 0.1s linear 45s forwards;
}
The playing screens are also simple. We have a clickGuard
class that sits over the
top of everything and prevents clicks from reaching the hitBoxes
.
Then a level
which we apply the playing animation, simply translating itself downwards over a
pre-determined amount of time.
Inside level
is a series of hitoxes
(styled checkboxes) that are generated at random top offsets.
<div class="playingScreen">
<div class="clickGuard"></div>
<div class="level">
<input type="checkbox" class="hitbox col3" style="top: -200px"></input>
<input type="checkbox" class="hitbox col3" style="top: -300px"></input>
<input type="checkbox" class="hitbox col4" style="top: -450px"></input>
<!-- ... -->
</div>
<div class="hitboxIndicator col1"></div>
<div class="hitboxIndicator col2"></div>
<div class="hitboxIndicator col3"></div>
<div class="hitboxIndicator col4"></div>
<div class="scoreBoard"></div>
<div class="streakBoard"></div>
</div>
At the bottom you'll see the hitboxIndicator
s, scoreBoard
and streakBoard
. They
are the static parts of the game for showing where you can click, and for displaying score/streaks.
Scores and streaks are implemented using CSS counters. The logic for these is below.
#game {
// ...hidden for brevity
counter-reset: score streak;
}
.hitbox:checked {
counter-increment: score; // Increase our score counter
pointer-events: none; // Don't let us uncheck a checked box
animation: hitAnim 0.3s;
}
// Each time we miss a box, then hit the next one, restart our streak counter
// because we must have missed one
.hitbox:first-of-type:checked,
.hitbox:not(:checked) + .hitbox:checked {
counter-reset: streak;
counter-increment: score streak;
}
// Since it resets every time we miss one and start a new streak, we simply
// increment every time we have two checked boxes in a row
.hitbox:checked + .hitbox:checked {
// Note: We must increment BOTH counters here as this rule will trump the
// normal score incrementing
counter-increment: score streak;
}
// Displaying our score
.scoreBoard::after {
display: block;
content: "Score: " counter(score);
}
// Displaying our streak
.streakBoard::after {
display: block;
content: "Streak: " counter(streak) "x";
}
The set of notes you'll see when playing is generated random every time the site is built and deployed, but from then on is static.
We still manage to make the game feel random by doing a little trick on the first screen. What looks like 1 Play!
button, is actually
4 play buttons stacked on top of each other with an animation that makes them invisible at different times to each other.
<div class="levelSelectButtons">
<label for="level-1" class="playButton b1">Play!</label>
<label for="level-2" class="playButton b2"> </label>
<label for="level-3" class="playButton b3"> </label>
<label for="level-4" class="playButton b4"> </label>
</div>
The first one is always visible, the others are set to loop animations, but out of sync with each other
.levelSelectButtons .playButton {
margin-left: -80px;
width: 80px;
position: absolute;
left: 190px;
}
// We debliberately don't animate the "bottom" button so that it is always visible underneath
// to prevent any flashing of text
// .levelSelectButtons .playButton[for="level-1"] {
// animation: toggleVisible 2s steps(1, end) 0.5s infinite;
// }
.levelSelectButtons .playButton[for="level-2"] {
animation: toggleVisible 2s steps(1, end) 0.5s infinite;
opacity: 0;
pointer-events: none;
}
.levelSelectButtons .playButton[for="level-3"] {
animation: toggleVisible 2s steps(1, end) 1s infinite;
opacity: 0;
pointer-events: none;
}
.levelSelectButtons .playButton[for="level-4"] {
animation: toggleVisible 2s steps(1, end) 1.5s infinite;
opacity: 0;
pointer-events: none;
}
Each of those buttons are labels for more radio buttons that control which level to display. A hitBox
will always have a
class of level1
, level2
, level3
or level4
, so each one of the radios makes 1/4 of the notes visible.
#level-1:checked ~ #game .level1 { display: initial; }
#level-2:checked ~ #game .level2 { display: initial; }
#level-3:checked ~ #game .level3 { display: initial; }
#level-4:checked ~ #game .level4 { display: initial; }
If you want to see this trick in action, try adding #debug to the end of the url!
This is a pretty simple one, but is useful for showing how the game works! We simply have an element with an id
of debug
and we can
select that with the :target
pseudo-selector
#debug:target ~ [name="game-state-level"]{
display: block;
&::after {
content: attr(name) " " attr(id);
display: inline-block;
width: 150px;
}
}
Similar to all the above, the gameOverScreen
has elements we can select to
write our counters to, and a reset button to restart the game. This works by clearing
the whole form the game is inside of and returning us to the first menu.
<div class="gameOverScreen">
<h2>Game Over!</h2>
<p id="game-over-score"></p>
<p id="game-over-streak"></p>
<input type="reset" id="resetButton" class="playButton" value="Play Again!"></input>
</div>
We use a couple of animations to switch between the gameScreen
and the gameOverScreen
but they're super messy at the moment.
Finally, the maxStreak
feature is done very hackily. There's no way we can use counters to solve this problem
without the ability to "reset" them back to a specific value.
The trick to this ended up being generating 60
CSS rules that cover every possible maxStreak
you can get!
.maxStreakBoard::after {content: "Max streak: 0x";}
.hitbox:checked ~ .maxStreakBoard::after {content: "Max streak: 1x";}
.hitbox:checked + .hitbox:checked ~ .maxStreakBoard::after {content: "Max streak: 2x";}
// ...
The really cool part about this is it doesn't interfere with the current counters and because each rule gets more and more specific, they automatically trump each other correctly!
Perspective shift
I played with the idea of doing a Guitar Hero style perspective shift so that the notes appear to be coming out of a vanishing point as they come towards you. I ran into some issues with it, but this should 100% be achievable.
High Score
Some way of storing the high score between games. So far I have no ideas. We can't use counters and we can't use the same trick as maxStreak without some sort of extra logic for knowing which game's score to show. Even then it would mean refactoring how random games and resetting works...
Theme Picker
I don't think this is the most useful feature, but it's one more interesting thing that could be tacked on quite easily.
We can display an "Options" button on the first screen that is a link to #options
. We can use the :target
pseudo selector to then
show a screen that lets the user pick between different background patterns for the game and a button to go back to the main screen.
- github-corners by tholman used under MIT license
- material-design-icons by Google used under Apache license