A Pen by HARUN PEHLİVAN on CodePen.
Created
March 11, 2022 17:08
-
-
Save harunpehlivan/c4401ca2d8924f1225de6b8f4b16c18c to your computer and use it in GitHub Desktop.
XState Wordle
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| .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] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" }); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| :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