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.
Having these in mind, let's proceed.
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:
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:
Word selection
Word check
Game completion
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!!!!
19th November, 2024
3rd October, 2024
12th July, 2023