Notes

How to build an SSR crossword puzzle with Nuxt 3

Last updated: 19th November, 2024   Author: Ugochukwu Okeke

I recently built a portfolio website for my good friend, James Chimdindu , a crazy good designer — and of course, it wasn't a normal one 😀. It featured a crossword puzzle amongst other things, and man did it get me excited.


In this article, I will share my thought process while building.


Note: This article is not beginner-friendly. You will need a substantial knowledge of Nuxt 3 to understand and use this article.


Before we delve into code, let's set some considerations for the puzzle.


  • For the UI, we want it server-rendered for better performance.

  • The puzzle will only support horizontal and vertical selections, no diagonals.

  • We want to save a user's progress so some data will persist, in this case, cookies. (Nuxt's SSR-friendly useCookie composable got us covered).

  • The puzzle will only reset when cookies are cleared manually or when the user finds all the secret words.

  • A user will select by clicking and dragging, similar to highlighting, but tweaked.
    I will explain later.

  • The puzzle will be available only on desktop view.

  • We will be using pinia for general state management.

  • Lastly, styling will be a combination of Tailwind CSS and SASS. You can use whichever you want - this was just my choice for this project.

Having these in mind, let's proceed.


Creating the UI


To achieve this design, we also needed to:
  • Create a 'dictionary' — an array of words, from which we would randomly select a few words.
  • Using the selected words, we will generate a 'matrix' — a 2-dimensional array that represents the layout of the puzzle. This we will use as the foundation of the puzzle markup.

A 2D array is just a one-step nested array, like so:


   const matrix = [
      ['A','B','C','D']
      ['E','F','G','H']
      ['I','J','K','L']
      ['M','N','0','P']
   ]

This will allow us to target the grid cells by nested index.


   console.log(matrix[0][0]) // A => row 0 col 0
   console.log(matrix[0][1]) // B => row 0 col 1
   console.log(matrix[0][2]) // C => row 0 col 2
   console.log(matrix[2][1]) // J => row 2 col 1, and so on

Array[outerIdx][innerIdx] easily translates to Table[rowIdx][colIdx], perfect!
This will also help in properly visualizing the puzzle as a table with rows and columns (0 as start).


Also using a cell's index, we can give it special classes, IDs, attributes, basically whatever unique data we need it to bear.


Now to generate the puzzle matrix itself, I found an npm package. crossword-layout-generator We install:


   npm i crossword-layout-generator

Then create the dictionary: (PS: the puzzle generator needs the words in this format. Check the documentation)


db/dictionary.json

   { "clue": "a pleasant visionary experience", "answer": "daydream" },
   { "clue": "deriving ideas or style from diverse sources", "answer": "eclectic" },
   { "clue": "extraordinarily good or attractive", "answer": "fantastic" },
   { "clue": "relating to galaxies or outer space", "answer": "galactic" },
   { "clue": "agreement or peaceful coexistence", "answer": "harmony" },
   { "clue": "marked by clever originality", "answer": "ingenious" },
   { "clue": "a feeling of great happiness and triumph", "answer": "jubilation" },
   { "clue": "a complex pattern of constantly changing colors and shapes", "answer": "kaleidoscope" },
   { "clue": "emitting or reflecting light", "answer": "luminous" },
   { "clue": "having a spiritual or mysterious quality", "answer": "mystical" },
   { "clue": "a state of perfect happiness or bliss", "answer": "nirvana" },
   { "clue": "a general tendency to expect the best", "answer": "optimism" },

Now we randomly pick a few word objects from the list:


utils/useWords.js

   import words from '@/db/puzzleWords.json'

   async function getRandomWords() {
      const result = [];
  
      for (let i = 0; i < n; i++) {
         const randomIndex = Math.floor(Math.random() * [...words].length);

         // Remove and add the random item
         result.push([...words].splice(randomIndex, 1)[0]); 
      }

      return result;
   }

   export { getRandomWords }

Before we feed the words to the generator, let's sort out data persist.


Ideally, we want a user to not lose their progress when they close the tab or refresh the page, so we need to save all the data related to the current puzzle. On refresh, the game is loaded with the same matrix and everything remains as it was before.


We create some constants (storage keys):


useConstants.js

   // when working with sensitive data, you'd want to keep this in an env file, but oh well...
   const ENCRYPTION_KEY = "qwert-asdfgh-zxcvb";

   const MATRIX_STORAGE_KEY = "pmt";
   const MATRIX_STORAGE_KEY = "pts";

   const NUMBER_OF_SECRET_WORDS = 8;
   const SECRET_WORDS_STORAGE_KEY = "srw";
   const SELECTED_WORDS_STORAGE_KEY = "svdw"

   const PUZZLE_COLUMN_NUMBER = 12;
   const PUZZLE_ROW_NUMBER = 16;

   export {
      ENCRYPTION_KEY,

      MATRIX_STORAGE_KEY,
      MATRIX_STORAGE_KEY,

      NUMBER_OF_SECRET_WORDS,
      SECRET_WORDS_STORAGE_KEY,
      SELECTED_WORDS_STORAGE_KEY,

      PUZZLE_COLUMN_NUMBER,
      PUZZLE_ROW_NUMBER
   }

Using these storage keys, we check if there's any (valid) data in cookies. If there is not, we generate new data for the puzzle. So in a composable file:


composables/useMatrix.js

   async function getPuzzleMatrixData(savedMatrix, savedSecretWords) {

      // check if data there's data in cookie,
      if(savedMatrix.value) {
         return {
            fromCookie: true,

            // decrypt and return it
            matrix:  {
               table: decryptData(savedMatrix.value),
               result: decryptData(savedSecretWords.value),
            }
         }
      }

      // else generate new matrix
      else {
         const newMatrixData = await generateNewPuzzleMatrixData()

         return {
            fromCookie: false,
            matrix: newMatrixData
         }
      }
   }

   // PS: I modified the npm package to suit the codebase, then renamed to 'genCrosswordLayout' 
   // But it is pretty straight forward to use

   async function generateNewPuzzleMatrixData() {

      let words = await getRandomWords()

      // feed the words to the generator package
      const matrix = await genCrosswordLayout(words, PUZZLE_ROW_NUMBER, PUZZLE_COLUMN_NUMBER)

      /**
         the generated crossword matrix (matrix.table) looks like this: 

         [ 
            [ '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-' ],
            [ '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-' ],
            [ '-', '-', 'd', 'a', 'y', 'd', 'r', 'e', 'a', 'm', '-', 'e' ],
            [ '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', 'n' ],
            [ '-', 'i', 'n', 'g', 'e', 'n', 'i', 'o', 'u', 's', '-', 'd' ],
            [ '-', '-', '-', '-', 'u', '-', '-', '-', '-', '-', '-', 'u' ],
            [ '-', '-', '-', '-', 'p', '-', '-', '-', 'b', '-', '-', 'r' ],
            [ '-', '-', '-', '-', 'h', '-', 'n', '-', 'r', '-', '-', 'a' ],
            [ '-', '-', 'p', 'r', 'o', 'l', 'i', 'f', 'i', 'c', '-', 'n' ],
            [ '-', '-', '-', '-', 'r', '-', 'r', '-', 'l', '-', '-', 'c' ],
            [ '-', '-', '-', '-', 'i', '-', 'v', '-', 'l', '-', '-', 'e' ],
            [ '-', '-', '-', 'p', 'a', 'r', 'a', 'd', 'i', 'g', 'm', '-' ],
            [ '-', '-', '-', '-', '-', '-', 'n', '-', 'a', '-', '-', '-' ],
            [ '-', '-', '-', '-', '-', '-', 'a', '-', 'n', '-', '-', '-' ],
            [ '-', '-', '-', '-', '-', '-', '-', '-', 't', '-', '-', '-' ],
            [ '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-', '-' ] 
         ]

      **/


      // replace dash '-' with a randomLetter
      matrix.table = matrix.table.map(row => 
         row.map(cell => (cell === '-' ? chooseRandomLetter() : cell))
      );

      // remove words that didn't make it into the layout, if there are any (read package docs)
      matrix.result = matrix.result.filter(word => word.orientation !== 'none')
      
      return matrix
   }


   async function getPuzzleMatixState(savedMatrixstate, matrix) {
      if(savedMatrixstate.value) {
         return {
            fromCookie: true,
            state: decryptData(savedMatrixstate.value)
         }
      }
      else {
         const newMatrixState = await generateNewMatrixState(matrix)

         return {
            fromCookie: false,
            state: newMatrixState
         }
      }
   }


   async function generateNewMatrixState(matrix) {

      // change all the cells that have letters to a false, to signify they've not been selected yet
      return matrix.map(row => 
         row.map(cell => (cell === '-' ? cell : false))
      );

      /**
         in the end, we have something like this.
         This will be mapped to the puzzle cells as a toggle for the 'selected class'

         [ 
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ],
            [ false, false, false, false, false, false, false, false, false, false, false, false ] 
         ]

      **/
   }

   export {
      getPuzzleMatrixData,
      generateNewPuzzleMatrixData,

      getPuzzleMatixState,
      generateNewMatrixState
   }

Then create a 'puzzle' store file to cater for puzzle/game state:


store/puzzle.js

   import { defineStore, acceptHMRUpdate } from 'pinia'

   export const usePuzzleStore = defineStore('puzzle', () => {

      const storageStore = useStorageStore()

      const matrixStorage = useCookie(MATRIX_STORAGE_KEY)
      const matrixStateStorage = useCookie(MATRIX_STATE_STORAGE_KEY)

      const secretWordsStorage = useCookie(SECRET_WORDS_STORAGE_KEY)
      const selectedWordsStorage = useCookie(SELECTED_WORDS_STORAGE_KEY)

      const matrix = reactive({
         table: [],

         isAvailable: false,

         state: [], 

         disabled: false,

         ended: false
      })
      
      const words = reactive({
         list: [],
         selected: [],
         isAvailable: false
      })

      async function initGame() {

         // data the puzzle needs
         let storageValues = [
            matrixStorage.value,
            matrixStateStorage.value,
            secretWordsStorage.value,
            selectedWordsStorage.value
         ]

         let falseValues = [ null, undefined, '', ' ' ]

         let storageValueIsMissing = falseValues.some(fvalue => storageValues.includes(fvalue)) 

         // check if any is missing, empty or invalid
         if (storageValueIsMissing) {

            // if there is, empty storage
            await storageStore.clearStorage()
         }
                  
         // get puzzle matrix data
         let matrixData = await getPuzzleMatrixData(matrixStorage, secretWordsStorage)

         // get puzzle matrix state
         let matrixStateData = await getPuzzleMatixState(matrixStateStorage, JSON.parse(JSON.stringify(matrixData.matrix.table)))

         // send data to store
         setMatrix({
            table: matrixData.matrix.table,
            state: matrixStateData.state
         })

         // send data to store
         setWords({
            list: matrixData.matrix.result,
            selected: selectedWordsStorage.value ? decryptData(selectedWordsStorage.value) : []
         })

         // if it's newly generated data, save to storage
         if (!matrixData.fromCookie) {
            storageStore.updateMatrixStorage(matrixData.matrix.table)
            storageStore.updateMatrixStateStorage(matrixStateData.state)

            storageStore.updateSecretWordsStorage(matrixData.matrix.result)
            storageStore.updateSelectedWordsStorage([])
         }
      }

      function setMatrix(payload) {
         matrix.table = payload.table
         matrix.state = payload.state
         matrix.isAvailable = true
      }

      function setWords(payload) {
         words.list = payload.list
         words.selected = payload.selected
         words.isAvailable = true
      }

      return {
         matrix, words, setMatrix, 
         setWords, initGame, game
      }
   })

   if (import.meta.hot) {
      import.meta.hot.accept(acceptHMRUpdate(usePuzzleStore, import.meta.hot))
   }

And a 'storage' store file for managing and updating storage.


store/puzzle.js

   import { defineStore, acceptHMRUpdate } from 'pinia'


   export const useStorageStore = defineStore('storage', () => {
      const matrixStorage = useCookie(MATRIX_STORAGE_KEY)
      const matrixStateStorage = useCookie(MATRIX_STATE_STORAGE_KEY)

      const secretWordsStorage = useCookie(SECRET_WORDS_STORAGE_KEY)
      const selectedWordsStorage = useCookie(SELECTED_WORDS_STORAGE_KEY)

      function updateMatrixStorage(table) {
         matrixStorage.value = encryptData(JSON.parse(JSON.stringify([...table])))
      }

      function updateMatrixStateStorage(stateTable) {
         matrixStateStorage.value = encryptData(JSON.parse(JSON.stringify([...stateTable])))
      }

      function updateSecretWordsStorage(words) {
         secretWordsStorage.value = encryptData(JSON.parse(JSON.stringify([...words])))
      }

      function updateSelectedWordsStorage(words) {
         selectedWordsStorage.value = encryptData(JSON.parse(JSON.stringify([...words])))
      }

      async function clearStorage() {
         matrixStorage.value = undefined
         matrixStateStorage.value = undefined
         secretWordsStorage.value = undefined
         selectedWordsStorage.value = undefined
      }

      return {
         updateMatrixStorage,
         updateMatrixStateStorage,
         updateSecretWordsStorage,
         updateSelectedWordsStorage,
         clearStorage
      }
   
   })

   if (import.meta.hot) {
      import.meta.hot.accept(acceptHMRUpdate(useStorageStore, import.meta.hot))
   }

Other helper functions:


utils/useCrypto.js

   import CryptoJS from 'crypto-js'

   // data encryption before storage
   function encryptData(data) {
      const ciphertext = CryptoJS.AES.encrypt(JSON.stringify(data), ENCRYPTION_KEY).toString();
      return ciphertext;
   }

   // data decryption after fetching from storage
   function decryptData(ciphertext) {
      const bytes = CryptoJS.AES.decrypt(ciphertext, ENCRYPTION_KEY);
      
      const decryptedData = bytes.toString(CryptoJS.enc.Utf8);
      return JSON.parse(decryptedData);
   }

   export {
      encryptData,
      decryptData
   }

utils/useUtils.js

   function chooseRandomLetter() {
      return String.fromCharCode(97 + Math.floor(Math.random() * 26));
   }

   export {
      chooseRandomLetter
   }

We then start the game in our App.vue file:


App.vue

   <script setup>
      const puzzleStore = usePuzzleStore()
      
      // this composable helps make sure this runs only the server
      // https://nuxt.com/docs/api/utils/call-once

      await callOnce(async () => {

         // prevent code from rerunning on client side navigation
         if (!puzzleStore.matrix.isAvailable) {
            await puzzleStore.initGame()
         }

      })

   </script>

At this point, data is available for the game. So we create the puzzle component proper:


components/puzzle.vue

   <script setup>
      const puzzleStore = usePuzzleStore()

      function getUniqueId(index) {
         const position = getPositionInMatrix(index)
         return `row-${position.row}-col-${position.col}`
      }

      const { matrix, words, wordsListStrings } = storeToRefs(puzzleStore)
   </script>

   <!-- puzzle grid - you can style this hoverever you want -->
   <div 
      class="
         puzzle w-[100%] h-[100%] text-2xl uppercase body-text 
         grid grid-cols-[repeat(12,_minmax(0,_1fr))] grid-rows-[repeat(16,_minmax(0,_1fr))]

         text-black-64 dark:text-white-64 relative

         [&>div]:grid [&>div]:place-items-center
         p2xl:text-[1.6vw]
      "
   >

      <!-- as mentioned ealier we use the row[col] index of each cell 
      to gen. unique data for the cells -->

      <div 
         v-for="(letter, idx) in matrix.table.flat()"  
         :key="getUniqueId(idx)" :id="getUniqueId(idx)"

         class="puzzle__cell opacity-0 text-black-40 dark:text-white-40"
         :class="{'selected': matrix.state[getPositionInMatrix(idx).row][getPositionInMatrix(idx).col] === true}"

         :data-matix-x="getPositionInMatrix(idx).row"
         :data-matix-y="getPositionInMatrix(idx).col"
      >
         {{ letter }}
      </div>
   </div>

The SSR capabilities of pinia, would help smoothly transfer the generated data on the server to the client for hydration.


The generated UI would look like this:


screeshot-of-generated-puzzle-ui


Next, we need to add a hover and selected states to grid cells. In the template section of the puzzle component, a 'selected' class was bound:


   :class="{'selected': matrix.state[getPositionInMatrix(idx).row][getPositionInMatrix(idx).col] === true}"

It will be conditionally applied based on the value (boolean) it maps to in the matrix.state array, in the puzzle store


We flattened the matrix.table array to be able to iterate over it without nesting, so we create a helper function to get the original col and row indexes.


composables/useMatrix.js

   function getPositionInMatrix(index) {
      let row =  Math.floor(index / PUZZLE_COLUMN_NUMBER);
      let col = index % PUZZLE_COLUMN_NUMBER;

      return {
         row: row,
         col: col
      }
   }

Having that sorted, we add some styling for the states. (You can do this however you want)


App.vue

   <script setup>
      const themeStore = useThemeStore()  
      const { theme } = storeToRefs(themeStore)
   </script>

   <template>
      <div 
         class="app___ lg:overflow-y-hidden"
         :class="{ 'dark': theme.stored === 'dark' }"
      >
         <NuxtLayout class="bg-jc-white dark:bg-jc-black">
            <NuxtPage />
         </NuxtLayout>
      </div>
   </template>

   <style lang="scss">
      @import '@/style/partials/colors';
      @import '@/style/partials/mixins';

      .app___ {
         // custom cursor for better ux
         cursor: url('@/assets/img/png/cursor-dark.png'), default;

         @mixin glowState {
            color: $black64;
            -webkit-text-stroke: 0.05rem rgb(0, 0, 0, 10%);
            filter: drop-shadow(0px 0px 8px black) drop-shadow(0px 0px 8px rgba(0, 0, 0, 0.075));
         }

         @mixin glowStateDark {
            color: $white64;
            -webkit-text-stroke: 0.0313rem rgba(255, 255, 255, 10%);
            filter: drop-shadow(0px 0px 8px $white64);
         }

         // layout container
         & > * {
            transition: .3s;
         }

         // hover and selected states
         .puzzle__cell {
            &:hover,
            &.selected {
               @include glowState;
            }
         }

         &.dark {
            cursor: url('@/assets/img/png/cursor-light.png'), default;

            .puzzle__cell {
               &:hover,
               &.selected {
                  @include glowStateDark;
               }
            }
         }
      }
   </style>

Add some styles in the puzzle component:


components/puzzle.vue

   <style lang="scss" scoped>
      .puzzle {

         // prevent highlighting & dragging
         user-select: none;
         -webkit-user-select: none;
         -webkit-user-drag: none;

         &.disabled {

            // disable pointer events 
            pointer-events: none;
         }
         
         &__cell {
            transition: 1s;
            user-select: none;
            -webkit-user-select: none;
            -webkit-user-drag: none;

            &::selection,
            &::-moz-selection {
               color: inherit;
               background: transparent;
            }
            
            &:hover {
               transition: 0s;
            }

            &.staging {
               @apply bg-black-4 dark:bg-white-4;
            }

            &.selected {
               transition: background-color .3s;
            }
         }
      }
   </style>

I didn't include it but, I'd already set up dark/light themes, mixins etc. in my codebase.

With the UI sorted, next up is:


The game logic


Word selection


  • A user makes a selection by clicking and dragging, just like highlighting (except they won't be highlighting — stay with me).

  • There's no native method for this, and using actual highlighting won't work, so we improvise.

  • On a 'mouse-down' event we start a 'recorder'.

  • On a 'mouse-over' event after 'mouse-down', we record (and add a 'staging' class) to all the elements (grid cells) the cursor hovered on.

  • On a 'mouse-up' event after 'mouse-over', we stop the recorder, get the letters in the recorded elements and join them together.

Word check


  • We then check if the word formed (forward and reversed), matches any of the secret words picked from the dictionary.

  • If it doesn't, we remove the staging class from all the recorded elements and reset for another selection.

  • If it does, we remove the staging class and add a selected class to the recorded elements. Then update the app state and storage.

Game completion


  • When all the secret words have been found, confetti! Storage is cleared and the game is ended and resets on refresh.

We update the puzzle component. Add the following event listeners to .puzzle element.


   @mousedown="startSelection"
   @mouseover="trackSelection"
   @mouseup="endSelection"

Then we create a composable file and add the game logic:


composbles/usePuzzle.js

   // temp storage for selected elements
   const selected = reactive({
      elements: [],
      letters: [],
      word: ''
   })

   let isDragging = false

   function addStagingState(el) {
      if (!el.classList.contains('staging')) {
         el.classList.add('staging')
      }
   }

   function removeStagingState(el) {
      el.classList.remove('staging')
   }

   function addSelectedState(el) {
      if (!el.classList.contains('selected')) {
         el.classList.add('selected')
      }
   }

   function startSelection (event) {
      if (event.target.classList.contains('puzzle__cell')) {

         // reset selection
         selected.elements = [] 
         
         // start recorder
         isDragging = true

         // add staging class for the first element
         addStagingState(event.target)

         // record first element
         addCellToSelection(event.target)
      }
   }

   function trackSelection(event){
      if (isDragging && event.target.classList.contains('puzzle__cell')) {
         
         // add staging class for all other elements hovered on
         addStagingState(event.target)

         // record them
         addCellToSelection(event.target)
      }
   }
   
   function addCellToSelection(element) {
      selected.elements.push(element)
   }

   function endSelection() {

      if (isDragging) {

         // end recorder
         isDragging = false

         // get letters from all the recorded elements
         selected.letters = selected.elements.map(cell => cell.textContent)

         // form word
         selected.word = selected.elements.map(cell => cell.textContent).join('')

         checkSelection()
      }
   }

   function checkSelection() {
      const puzzleStore = usePuzzleStore()
      const storageStore = useStorageStore()

      let secretWords = puzzleStore.wordStrings.list
      let selectedWords = puzzleStore.wordStrings.selected

      function isSelectionCorrect() {
         
         // check if selection or it's reverse is correct
         return secretWords.includes(selected.word) || secretWords.includes(selected.word.split('').reverse().join(''))
      }

      function selectionExits() {
         return selectedWords.includes(selected.word)
      }

      // check if the selection is correct and hasn't been made already
      if (isSelectionCorrect() && !selectionExits()) {

         // get the matched word from the list of secret word objects
         let nw = puzzleStore.words.list.filter(word => word.answer === selected.word)

         // add it to the list of selected words
         puzzleStore.updateSelectedWords(nw[0])

         selected.elements.forEach(el => {
            
            // unstage
            removeStagingState(el)

            // get the element's row and col index from its data attribute...
            // and toggle it's selected state in the the matrix state array
            puzzleStore.updateMatrixState({
               x: el.getAttribute('data-matix-x'),
               y: el.getAttribute('data-matix-y')
            })

         })
         
         // update storage
         storageStore.updateMatrixStateStorage(puzzleStore.matrix.state)
         storageStore.updateSelectedWordsStorage(puzzleStore.words.selected)

      }

      // if selection does not matchany word, unstage all the recorded elements
      else {
         selected.elements.forEach(el => {
            removeStagingState(el)
         })
      }
   }

   export {
      startSelection,
      trackSelection,
      endSelection,
      addCellToSelection
   }

Update puzzle store:


store/puzzle.js

   const wordStrings = computed(() => {
      return  {
         list: words.list?.map(word => word.answer),
         selected: words.selected?.map(word => word.answer)
      }
   })

   function updateMatrixState(coordinates) {
      matrix.state[coordinates.x][coordinates.y] = true
   }

   function updateSelectedWords(word) {
      words.selected.push(word)
   }

Result:



Now we watch for when all the words have been found and end the game. But first let's add a confetti (you can choose not to in your build).


I found a nice npm package, vue-confetti-explosion that works. Install using:


   npm i vue-confetti-explosion

pages/index.vue

   <script setup>
      import ConfettiExplosion from "vue-confetti-explosion";

      const themeCookie = useCookie(THEME_STORAGE_KEY)

      // set what colors you want here or directly in the template
      const CONFETTI_COLORS = computed(() => {
         if (themeCookie.value === 'light') {
            return ['#FFC700', '#8f8f8f', '#FF0000', '#c7c7c7']
         }
         else {
            return ['#FFC700', '#a9a9a9', '#FF0000', '#373737']
         }
      })

      // set whatever width you want here or directly in the template
      const stageWidth = computed(() => {
         if (process.client) {
            return document.querySelector('.home').clientWidth + 100
         }
      })
   </script>

   <template>

      <!-- be sure to wrap the component in client-only so it doesn't break the app server-side -->
      <ClientOnly>
         <ConfettiExplosion 
            v-if="game.ended"
            :force="0.2" 
            :particleCount="250" :particleSize="10"
            :shouldDestroyAfterDone="false" 
            :stageWidth="stageWidth" :stageHeight="2000"
            :duration="CONFETTI_DURATION"
            :colors="CONFETTI_COLORS" 
            class="mx-auto relative top-10"
         />
      </ClientOnly>
   </template>

Update constants:


utils/constants.js

   const CONFETTI_DURATION = 5000;
   const THEME_STORAGE_KEY = "theme";

Result:



That wraps it up.


Obviously I didn't just paste the entire codebase as is, the goal of the article is mostly to share my thought process.


If you're building something similar, you'd most likely adjust things to suit your needs and design. But yeah, this would help you.


You can also check out the live puzzle at jameschimdindu.com.


If you need some clarity in your project, or have any other feedback (which I would appreciate), please free to reach out to me on Twitter @thecavydev.


Special thanks to the creators and the maintainers of the npm packages used. Also James Chimdindu, for carrying me along on this one. Hehehe.


Until next time.


ARIGATO GOSAIMASU!!!!

More from ColoSach

Related articles, news and information.
how-to-build-a-crossword-puzzle-with-nuxt-ssr
I recently built a portfolio website for my good friend, James Chimdindu, a crazy good designer — and of course, it wasn't a normal one 😀. It featured a crossword puzzle amongst other things
how-to-implement-theming-in-ssr-nuxt-tailwind
Typically in CSR (client-side rendered) websites, working with Tailwind, your theme setup would probably look something like this. This usually works fine for client-rendered websites. But it doesn't really suffice for SSR (server-side rendered) websites . Why?
sample-blog-title-placeholder-2
This is a sample blog description. It should exclude headings and can span up to four lines of text. This is a sample blog description. It should exclude headings and can span up to four lines of text. This is a sample blog description. It should exclude headings and can span up to four lines of text. span three lines of text.
Toast mssg