https://blog.scottlogic.com/2024/05/17/noJS-2-stochastic-boogaloo.html
Menu
* Home
* What we do
* Our work
* Who we are
* Careers
* Events
* News
* Blog
* Contact Us
Scott Logic / Altogether Smarter
* Careers
* Events
* News
* Contact
* What we do
* Our work
* Who we are
* Blog
Tech * 17 May 2024 * 8 min read
NoJS 2 - Stochastic Boogaloo. Making a tic-tac-toe game with
'randomness' using pure HTML and CSS. Without JavaScript!
[picture] Gurveer Arora
This is part two to this post where I explain how I made this pure
CSS calculator. Next up I made tictactoe, which isn't in itself that
interesting of an extension, but the challenge came in adding a CPU
to play "randomly". In this blog post I [DEL:make a case for my
sanity:DEL] explain how I made it.
Rules
The only thing I wrote was HTML and CSS. No HAML or SCSS or any other
pre-processors. The no JavaScript is enforced by testing the app with
JavaScript disabled by the browser. You can view my full codebase
here.
How did I make it?
Basic game logic
In my previous blog post, I've explained how we can use radio buttons
and labels to create the effect of buttons and state. This is pretty
much all we need for the basic game.
Start off by creating the inputs
Then each square of the game looks something like this
The first thing we need to do is ensure that the only label that can
be clicked is the one corresponding to who's turn it is. We do this
with
body:has(input:checked),
body:has(input[id^="X"]:checked ~ input[id^="X"]:checked),
/* more selectors for 3, 4 and 5 Xs*/
{
--has-x-just-played: 1;
}
body,
body:has(input[id^="O"]:checked),
body:has(input[id^="O"]:checked ~ input[id^="O"]:checked),
/* more selectors for 4 and 5 Os*/
{
--has-x-just-played: 0;
}
label[for^="O"] {
transform: scale(var(--has-x-just-played));
}
The first 2 rulesets are used to set a variable based on how many X's
and O's have been selected. The has operator is a relatively new one.
It allows you select parents based on children or select previous
siblings based on upcoming siblings. In this case, the variable
--has-x-just-played is always applied to the body element, this
allows us to avoid a lot of nesting and duplication of elements. The
final ruleset hides any label connected to an O input when it is X's
turn, so that the only clickable label is for X.
The simplest bit of the CSS in this app is displaying the symbols, by
default we set all symbols to display: none and then
/* display logic */
body:has(#X-1:checked) .board-square-1 .symbol-X,
body:has(#X-2:checked) .board-square-2 .symbol-X,
/* ... */
body:has(#O-1:checked) .board-square-1 .symbol-O,
/* ... */
{
display: block;
}
This rule could be simplified by moving each pair of inputs to just
before the corresponding square. That way the selectors could be made
relative and only 2 would be needed. However I prefer to keep all
inputs tightly together, this also makes the endgame logic slightly
more concise. Which looks like this
body:has(input[id^="X"]:nth-of-type(3n-2):checked + input:checked + input:checked), /* rows */
body:has(input[id^="X"]:checked + * + * + input:checked + * + * + input[id^="X"]:checked), /* columns */
body:has(input#X-1:checked ~ input#X-5:checked ~ input#X-9:checked), /* top-left to bottom-right diagonal */
body:has(input#X-3:checked ~ input#X-5:checked ~ input#X-7:checked), /* top-right to bottom-left diagonal */
{
--has-X-won: 1;
}
/* equivalent selectors for --has-O-won */
body:has(input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked ~ input:checked)
{
--is-board-full: 1;
}
body {
--is-game-over: max(var(--has-O-won), var(--has-X-won), var(--is-board-full));
--is-game-on: calc(1 - var(--is-game-over));
--has-drawn: calc(var(--is-game-over) - var(--has-X-won) - var(--has-O-won));
--is-X-to-play: calc(var(--is-game-on)*(1 - var(--has-x-just-played)));
--is-O-to-play: calc(var(--is-game-on)*var(--has-x-just-played));
}
Using the new variables --has-X-won, --has-O-won and --has-drawn we
can display the result of the game. We can also change the O label
scaling logic above to use --is-O-to-play and add an equivalent
ruleset for X, so that the label become hidden when the game is over.
A little bit of HTML and CSS is needed to move the squares into the
right location, but there is nothing special there.
Random
Now for the real fun part, how to create a [DEL:sophisticated AI:DEL]
bot to play in an evenly distributed 'random' manner. For this we
first create extra labels for all of the locations (for X and O).
They will be placed behind a div that will act as a button. If we
animate the labels and make them invisible, the user will not be able
to know which label is currently under the button.
The CSS looks like
.random.O {
display: flex;
flex-direction: column;
height: calc(var(--button-height) * 9 * var(--is-O-to-play));
}
/* repeat for "X random" */
.random label {
display: block;
width: var(--button-width);
flex-grow: 1;
}
.random-button-holder {
height: var(--button-height);
overflow: hidden;
box-shadow: var(--box-shadow);
}
.button { height: var(--button-height); }
What we have is a "button" with the word Random. The labels are
inaccessible due to the overflow: hidden. The height setting on
.random.{LETTER} ensures that only the relevant labels are sized at
any given time. Now to add some animation.
.random {
animation: 0.1s linear 0s infinite normal moving-button;
}
@keyframes moving-button {
from {
transform: translateY(calc(-1 * var(--button-height)));
}
to {
transform: translateY(calc(var(--button-height) * -9));
}
}
The 0.1s duration means that it becomes difficult to predict where in
the animation we are at. Here is a view of what it looks like
currently. the translucent blue panels show the labels.
Demonstration of the hidden labels used for randomisation
There are two problems with the current system. The first is that if
you click down on a label, move your mouse to another label and then
let go, neither label registers as clicked. The exact same thing
occurs if the label moves away, which is very likely given how fast
the animation is. The solution here is simple
.random:hover { animation-play-state: paused; }
The other problem is that not each block is equally likely to be
clicked. If you watch the video carefully you'll notice that the
first and last labels spend less time covering the button. A hacky
solution would be to make the first and last labels bigger. This
would fix the problem on average, but this still means the top of the
button would see the first label for longer than the bottom of the
button. So instead, I added all the label again inside another div.
.final {
flex-grow: 0;
height: var(--button-height);
}
By tweaking the animation slightly, we can ensure that only the top
label inside div.final ever makes it to the button. This effectively
means that whichever label is currently at the top (the lowest
numbered available input) has an equivelent at the bottom. We have to
ensure that the size of this div is fixed to the button height,
because that is the exact amount that is "lost" under the old system.
Another possible solution would have been to animate each section
individually so it joins the end of the queue once it's done,
allowing each label to fully cross the button without ever leaving a
gap. But this would throw complications when some buttons were
pressed and the labels had to be removed.
Audio
If you've played the game you've probably noticed an audio easter
egg. Before trying this I had thought that playing audio would
require JavaScript to function, however the audio control works with
JavaScript disabled at the browser level so I'm going to allow it.
The audio controls has many buttons (for volume, downloading etc). In
order to only make the play button accessible, we first wrap it in a
div and then apply the following CSS.
.div-around-audio {
height: 30px;
width: 30px;
border-radius: 20px;
overflow: hidden;
}
audio {
position: absolute;
top: -11px;
left: -11px;
}
The key part is overflow: hidden; which ensures the overflow of the
audio control (volume, scrub) cannot be interacted with, the rest of
the values were determined by trial and error.
And then the play button can be made invisible or placed behind an
element that has pointer-events: none;, which allows clicks to pass
through it.
Calculator Extension
Since making the calculator, the has has been introduced, which
allows selecting parents based on children. In the calculator this
could be used to avoid the excessive nesting. I also added basic
trigonometric functions, for this I followed the below steps
1. Use integer division (see previous post) to create a modulo
function and map the input to between 0 and 2p
2. Use a bunch of maths and retries to map the input to a value
between 0 and p/2
3. Painfully implement the expansion series of trig functions
4. Realise that while I've been playing around with this, trig
functions have been added the CSS spec
5. Question my life choices
Given the advances in the CSS spec to add trigonometric and other
mathematical functions, writing a full scientific calculator within
reach. While I have not yet attempted this, a possible limitation is
that the CSS spec only asks browsers to support up to 20 terms in a
calc, and while I haven't seen this exact limit in action, the main
browsers do give up after a certain amount of complexity.
FAQ
When are you going to stop with this madness?
I currently have no plans to continue
Did you write this blog post just so you could say the words
"Stochastic Boogaloo"?
No comment
Wrap up
While I was making these apps, there were many points at which I
deemed something to be 'impossible'. Things like functioning
decimals; an evenly distributed 'random' selector; and functioning
audio seemed out of scope for rules and format I had chosen. But
because I had the freedom to stop and think about this with no
deadline, I was able to overcome the challenges. When working with
practical problems, we are faced with real-world pressures and time
limits and this can often leave us with an incomplete view of the
true power of the tech we work with. I encourage anyone with an
interest to explore the limits of a technology and the power of their
creativity. Maybe this will help you in your day to day job. However
I wouldn't suggest rewriting any of your webapps in CSS only any time
soon.
Want to receive more insights?
If you enjoyed this blog post, why not subscribe to our mailing list
to receive Scott Logic content, news and insights straight to your
inbox? Sign up here.
Gurveer Arora Gurveer Arora
I am a developer at Scott Logic, Newcastle. Find me on Github
Categories
* Latest Articles
* Resources
* Cloud
* Tech
* UX Design
* Delivery
* Testing
* Artificial Intelligence
* Sustainability
* Data Engineering
* People
* Videos
* Open Source
* Podcast
Back to all posts
Scott Logic
Contact Us
* (c) Copyright Scott Logic 2024.
* Privacy
* Cookies Policy
* Modern Slavery Statement