Skip to content

Instantly share code, notes, and snippets.

@harunpehlivan
Created March 11, 2022 17:08
Show Gist options
  • Select an option

  • Save harunpehlivan/c4401ca2d8924f1225de6b8f4b16c18c to your computer and use it in GitHub Desktop.

Select an option

Save harunpehlivan/c4401ca2d8924f1225de6b8f4b16c18c to your computer and use it in GitHub Desktop.
XState Wordle
.game
header
h1
a href="https://www.powerlanguage.co.uk/wordle/" target="_blank" WORDLE
span  
a href="https://xstate.js.org" target="_blank" XSTATE
section.input
- for square in (1..(5*6))
.stateful id="square-#{square}"
section.restart
button Restart
footer
- for key in ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "A", "S", "D", "F", "G", "H", "J", "K", "L", "ENTER", "Z", "X", "C", "V", "B", "N", "M"]
.stateful data-key="#{key}" #{key}
.stateful.symbol data-key="BACKSPACE" ⌫
.invalid-word
p Word not in list
.modal
.modal-background
.modal-content
button.modal-close type="button" X
.modal-text
iframe[data-xstate]
import {
assign,
createMachine,
interpret
} from "https://cdn.skypack.dev/xstate";
import * as xstateInspect from "https://cdn.skypack.dev/@xstate/inspect";
xstateInspect.inspect({ url: "https://stately.ai/viz?inspect" });
const wordList = [
"bowel",
"equip",
"creep",
"spray",
"slice",
"sword",
"shape",
"flood",
"sweep",
"drink",
"lemon",
"enfix",
"shark",
"slide",
"steak",
"elect",
"wrong",
"image",
"suite",
"shine",
"clear",
"chuck",
"coins",
"cynic",
"cares",
"triad",
"graph",
"arrow",
"aroma"
];
/**
* @param id {number} ordinal position of the letter square, 1 being top left and 30 being bottom right
* @param letter {string} single letter to put in the square
* @param state {'correct' | 'incorrect' | 'misplaced' | ''} the state corresponds to the "green", "gray" and "yellow" styles on the answer square
*/
function setLetterSquare(
id: number,
letter: string,
state: "correct" | "incorrect" | "misplaced" | ""
) {
const element = document.getElementById(`square-${id}`);
element.innerHTML = letter.toUpperCase();
element.dataset.state = state;
}
/**
* @param key {string} single upper case key value to update the state
* @param state {'correct' | 'incorrect' | 'misplaced' | ''} the state corresponds to the "green", "gray" and "yellow" styles on the virtual keyboard
*/
function setKeyState(
key: string,
state: "correct" | "incorrect" | "misplaced" | ""
) {
const element = document.querySelector(`[data-key="${key.toUpperCase()}"]`);
if (element.dataset.state === "correct") return;
if (element.dataset.state === "misplaced" && state !== "correct") return;
element.dataset.state = state;
}
function getRandomWord() {
return wordList[Math.floor(Math.random() * wordList.length)].toUpperCase();
}
// Machine takes in one event of type { type: 'key', key: '<key value>'}.
// <key value> is one of A - Z, ENTER, BACKSPACE
const service = interpret(
createMachine(
{
schema: {
context: {} as { todaysWord: string; guessLetters: string[] },
events: {} as {
type: "key";
key:
| "A"
| "B"
| "C"
| "D"
| "E"
| "F"
| "G"
| "H"
| "I"
| "J"
| "K"
| "L"
| "M"
| "N"
| "O"
| "P"
| "Q"
| "R"
| "S"
| "T"
| "U"
| "V"
| "W"
| "X"
| "Y"
| "Z"
| "ENTER"
| "BACKSPACE";
}
},
id: "Wordle Game",
context: { todaysWord: getRandomWord(), guessLetters: [] },
initial: "Entering Mode",
states: {
"Entering Mode": {
initial: "1st Key",
states: {
"1st Key": {
on: {
KEY: [
{
cond: "Delete Key",
target: "#Wordle Game.Entering Mode.1st Key"
},
{
cond: "Enter Key",
target: "#Wordle Game.Entering Mode.1st Key"
},
{
actions: "Set Letter",
target: "#Wordle Game.Entering Mode.2nd Key"
}
]
}
},
"2nd Key": {
on: {
KEY: [
{
actions: "Clear Letter",
cond: "Delete Key",
target: "#Wordle Game.Entering Mode.1st Key"
},
{
cond: "Enter Key",
target: "#Wordle Game.Entering Mode.2nd Key"
},
{
actions: "Set Letter",
target: "#Wordle Game.Entering Mode.3rd Key"
}
]
}
},
"3rd Key": {
on: {
KEY: [
{
actions: "Clear Letter",
cond: "Delete Key",
target: "#Wordle Game.Entering Mode.2nd Key"
},
{
cond: "Enter Key",
target: "#Wordle Game.Entering Mode.3rd Key"
},
{
actions: "Set Letter",
target: "#Wordle Game.Entering Mode.4th Key"
}
]
}
},
"4th Key": {
on: {
KEY: [
{
actions: "Clear Letter",
cond: "Delete Key",
target: "#Wordle Game.Entering Mode.3rd Key"
},
{
cond: "Enter Key",
target: "#Wordle Game.Entering Mode.4th Key"
},
{
actions: "Set Letter",
target: "#Wordle Game.Entering Mode.5th Key"
}
]
}
},
"5th Key": {
on: {
KEY: [
{
actions: "Clear Letter",
cond: "Delete Key",
target: "#Wordle Game.Entering Mode.4th Key"
},
{
cond: "Enter Key",
target: "#Wordle Game.Entering Mode.5th Key"
},
{
actions: "Set Letter",
target: "#Wordle Game.Entering Mode.All Entered"
}
]
}
},
"All Entered": {
exit: "Clear Invalid Prompt",
on: {
KEY: [
{
actions: "Clear Letter",
cond: "Delete Key",
target: "#Wordle Game.Entering Mode.5th Key"
},
{
cond: "Enter Key",
target: "#Wordle Game.Entering Mode.Validate Word"
},
{
target: "#Wordle Game.Entering Mode.All Entered"
}
]
}
},
"Validate Word": {
always: [
{
actions: "Set Letter Hints",
cond: "Valid Word",
target: "#Wordle Game.Check Answer"
},
{
target: "#Wordle Game.Entering Mode.Prompt Invalid"
}
]
},
"Prompt Invalid": {
entry: "Prompt Invalid Word",
exit: "Clear Invalid Prompt",
after: {
1000: { target: "#Wordle Game.Entering Mode.All Entered" }
},
on: {
KEY: [
{
actions: "Clear Letter",
cond: "Delete Key",
target: "#Wordle Game.Entering Mode.5th Key"
}
]
}
}
}
},
"Check Answer": {
always: [
{
actions: "Condolences",
cond: "No More Guesses",
target: "#Wordle Game.Finish Mode"
},
{
actions: "Congratulate",
cond: "Correct Answer",
target: "#Wordle Game.Finish Mode"
},
{
actions: "Increment Row",
target: "#Wordle Game.Entering Mode.1st Key"
}
]
},
"Finish Mode": {
entry: "Show Reset Button",
on: {
RESTART: {
actions: "Restart",
target: "#Wordle Game.Entering Mode.1st Key"
}
}
}
}
},
{
actions: {
"Set Letter": assign((context, event, meta) => {
setLetterSquare(context.guessLetters.length + 1, event.key, "");
return {
...context,
guessLetters: [...context.guessLetters, event.key]
};
}),
"Clear Letter": assign((context, event, meta) => {
setLetterSquare(context.guessLetters.length, "", "");
return {
...context,
guessLetters: context.guessLetters.slice(0, -1)
};
}),
"Set Letter Hints": (context) => {
const todaysWordParts = context.todaysWord.split("");
const guessParts = context.guessLetters.slice(-5);
const offset = context.guessLetters.length - 4;
for (let i = 0; i < todaysWordParts.length; i += 1) {
const guess = guessParts[i];
if (todaysWordParts[i] === guess) {
todaysWordParts[i] = ""; // to prevent double matches of misplaced
setLetterSquare(offset + i, guess, "correct");
setKeyState(guess, "correct");
}
}
for (let i = 0; i < todaysWordParts.length; i += 1) {
const guess = guessParts[i];
if (!todaysWordParts[i]) {
continue;
} else if (todaysWordParts.includes(guess)) {
setLetterSquare(offset + i, guess, "misplaced");
setKeyState(guess, "misplaced");
} else {
setLetterSquare(offset + i, guess, "incorrect");
setKeyState(guess, "incorrect");
}
}
},
"Prompt Invalid Word": () => {
document.body.dataset.state = "invalid-prompt";
},
"Clear Invalid Prompt": () => {
delete document.body.dataset.state;
},
Congratulate: (context) => {
document.querySelector(".modal").classList.add("open");
document.querySelector(
".modal-text"
).innerHTML = `Congratulations! You got the correct word in ${
context.guessLetters.length / 5
} tries.`;
},
Condolences: (context) => {
document.querySelector(".modal").classList.add("open");
document.querySelector(
".modal-text"
).innerHTML = `Better luck next time. The correct answer was "${context.todaysWord
.substring(0, 1)
.toUpperCase()}${context.todaysWord.substring(1).toLowerCase()}".`;
},
"Show Reset Button": () => {
document.querySelector("section.restart").classList.add("visible");
},
Restart: assign(() => {
document.querySelector("section.restart").classList.remove("visible");
document.querySelectorAll("section .stateful").forEach((element) => {
element.innerHTML = "";
delete element.dataset.state;
});
document.querySelectorAll("footer .stateful").forEach((element) => {
delete element.dataset.state;
});
return { todaysWord: getRandomWord(), guessLetters: [] };
})
},
guards: {
"Delete Key": (_, event) => event.key === "BACKSPACE",
"Enter Key": (_, event) => event.key === "ENTER",
"Valid Word": (context) => {
const currentGuess = context.guessLetters
.slice(context.guessLetters.length - 5)
.join("");
return wordList.includes(currentGuess.toLowerCase());
},
"No More Guesses": (context) => context.guessLetters.length >= 30,
"Correct Answer": (context) => {
const currentGuess = context.guessLetters
.slice(context.guessLetters.length - 5)
.join("");
return currentGuess === context.todaysWord;
}
}
}
),
{ devTools: true }
);
service.start();
service.onTransition((state) =>
console.log({ context: state.context, value: state.value })
);
window.addEventListener("keypress", (event) => {
service.send({ type: "KEY", key: event.key.toUpperCase() });
});
document.querySelectorAll("[data-key]").forEach((element) =>
element.addEventListener("click", (event) => {
service.send({ type: "KEY", key: event.currentTarget.dataset.key });
})
);
document.querySelector(".modal-close").addEventListener("click", () => {
document.querySelector(".modal").classList.remove("open");
});
document
.querySelector("section.restart button")
.addEventListener("click", () => {
service.send({ type: "RESTART" });
});
:root {
font-family: sans-serif;
text-align: center;
--borderColor: #d3d6da;
--incorrectColor: #787c7e;
--correctColor: #6aaa64;
--misplacedColor: #c9b458;
--activeTextColor: white;
--defaultTextColor: #1a1a1b;
--guessSquareSize: 62px;
}
body {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
flex-direction: row;
height: 100vh;
width: 100vw;
align-items: stretch;
justify-items: stretch;
user-select: none;
}
.game {
display: flex;
flex-direction: column;
height: 100vh;
align-items: center;
justify-content: space-between;
user-select: none;
}
header {
width: 500px;
display: flex;
justify-content: center;
padding: 0.8rem;
font-size: 2rem;
font-weight: bold;
border-bottom: 1px solid var(--borderColor);
}
section {
display: grid;
grid-gap: 6px;
grid-template-columns: repeat(5, var(--guessSquareSize));
grid-template-rows: repeat(6, var(--guessSquareSize));
}
section > .stateful {
border: 2px solid var(--borderColor);
display: flex;
justify-content: center;
align-items: center;
font-size: 2rem;
line-height: 2rem;
font-weight: bold;
&[data-state]:not([data-state=""]) {
transform: rotatey(90deg);
animation: rotate-in ease-in-out 0.4s;
animation-fill-mode: forwards;
&:nth-child(2n) {
animation-delay: 0.1s;
}
&:nth-child(3n) {
animation-delay: 0.2s;
}
&:nth-child(4n) {
animation-delay: 0.3s;
}
&:nth-child(5n) {
animation-delay: 0.4s;
}
}
}
section.restart {
display: none;
&.visible {
display: flex;
justify-content: center;
}
}
@keyframes rotate-in {
0% {
transform: rotatey(90deg);
}
100% {
transform: rotatey(0deg);
}
}
$states: correct, incorrect, misplaced;
.stateful {
@each $state in $states {
&[data-state="#{$state}"] {
border-color: var(--#{$state}Color);
background: var(--#{$state}Color);
color: var(--activeTextColor);
}
}
}
footer {
margin: 0.8rem;
display: grid;
grid-template-rows: repeat(3, 58px);
grid-row-gap: 8px;
grid-template-columns: repeat(20, 1fr);
grid-column-gap: 6px;
> div {
cursor: pointer;
background-color: var(--borderColor);
grid-column-end: span 2;
border-radius: 4px;
padding: 12px;
display: inline-flex;
justify-content: center;
align-items: center;
font-weight: bold;
font-size: 0.8rem;
&.symbol {
font-size: 1.2rem;
}
}
> [data-key="A"] {
grid-column-start: 2;
}
> [data-key="ENTER"],
> [data-key="BACKSPACE"] {
grid-column-end: span 3;
}
}
@keyframes invalid-word-prompt {
0% {
visibility: visible;
opacity: 0;
}
10% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
visibility: hidden;
opacity: 0;
}
}
.invalid-word {
visibility: hidden;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 14px;
padding: 16px 24px;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 18px;
line-height: 18px;
font-weight: bold;
}
@keyframes shake {
10%,
90% {
transform: translate3d(-2px, 0, 0);
}
20%,
80% {
transform: translate3d(3px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-8px, 0, 0);
}
40%,
60% {
transform: translate3d(8px, 0, 0);
}
}
body[data-state="invalid-prompt"] {
.invalid-word {
animation: invalid-word-prompt 2.4s linear;
}
section.input [data-state=""] {
animation: shake 0.8s linear;
}
}
@keyframes open-animation {
0% {
visibility: visible;
opacity: 0;
}
100% {
opacity: 1;
}
}
.modal {
visibility: hidden;
z-index: 100;
&.open {
visibility: visible;
animation: open-animation ease-in-out 0.4s;
animation-fill-mode: forwards;
}
.modal-content {
position: fixed;
width: 80vw;
height: 200px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 4px solid green;
background: white;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 1.4rem;
z-index: 2;
padding: 1.8rem;
.modal-close {
position: absolute;
top: 4px;
right: 4px;
color: red;
}
}
.modal-background {
top: 0;
left: 0;
display: block;
content: "";
position: fixed;
height: 100vh;
width: 100vw;
background: rgba(0, 0, 0, 0.4);
z-index: 1;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment