Menu
Tags

SCRAM - A 'Wordle' Style Word Game.

Published on Feb 26, 2024

Table of Contents

SCRAM - A word game.

Jokingly, I mentioned to my partner during our usually daily Wordle race that ‘I really need to make one of these and sell it so I can retire early’. The next day, she came back with an idea, and I got to cracking. The idea is simple, you’ve got 30 seconds to un-scramble a set of words, with each word getting longer as you go.

If you’d like, you can play it right now: scram.barnes.lol.

If you’d like a peak at the code, the repo is here

The Stack

  • Sveltekit - Client-side logic only. Overkill for this, but I’m familiar with in, so I went for it.
  • Picocss - Styling remains largely un-touched, just using Picocss.
  • Turso - Sqlite DB for storing game data, to help improve the game over time.
  • Playwright - e2e browser testing.
  • Vitest - Unit Tests for the game logic.

The Game Logic

I think I over-thought and under-engineered the game logic simultaneously. Basically, we’ve got a large ‘gameState’ object that keeps track of the ongoing game. Then, when the player starts, we enter a setInterval hook, and update the gameState based on the users guesses. I moved all the fluff of updating the gameState object out to a seperate file to make the route page a touch cleaner.

/src/+page.svelte
let gameState: GameState = $state({
		timer: 0,
		timerWidth: '100%',
		timerColor: green,
		state: 'start', //start, playing, end
		currentWordCount: 0,
		currentShuffle: todaysPuzzle[0][1],
		currentWord: todaysPuzzle[0][0],
		guess: '',
		correctCount: 0,
		correctWords: [],
		completeGame: false,
		lastWord: ''
	});

...

    const startGame = () => {
		gameState = initGame(gameState);
		const timer = setInterval(() => {
			if (gameState.currentWord === gameState.guess.toLowerCase().replaceAll(' ', '')) {
				gameState = correctGuess(gameState, todaysPuzzle, timer, record, todaysDate);
			}
			gameState = gameTick(gameState);
			if (gameState.timer <= 0) {
				 gameState = gameOver(gameState, timer,record, todaysDate, todaysPuzzle);
			}
		}, 150);
	};

Within the game object, you’ll see the ‘width’ key. I’m using this to dynamically update the width of the timer bar.

If we want to break the logic down a touch:

  1. A player presses start.
  2. We initialize the gameState object, and start a timer.
  3. After each 150ms interval, we check the players guess
    • If the guess is correct:
      • Update the next word and its scrambled pair, store the correct word in the ‘correctWords’ array. Reset the guess.
      • If there are no more words in the array, set the gameState to complete, update the localStorage and send the result to the DB.
    • If the timer turns to 0:
      • Set gameState to complete, update localStorage and send results to the database.
    • Update the width attribute.

The Svelte Frontend:

I created a few helper components, but largely the page is defined in the route itself.

./src/+page.svelte
<div class="game">
	{#if gameState.state == 'start' && lastPuzzle != todaysDate}
		<article>
			<p>
				You've got 30 seconds to un-scramble as many words as possible. Your score is the number of
				words you clear. Get a 'perfect' game by unscrambling all 6 words!
			</p>
			<p>
				<em
					>This game is in very, very early stages of development. Send feedback to ryan@barnes.lol</em
				>
			</p>
		</article>
	{/if}

	<div class="game-container">
		{#if gameState.state == 'start' && lastPuzzle != todaysDate}
			<div class="button">
				<button on:click={startGame}>Play!</button>
			</div>
		{:else if gameState.state == 'playing'}
			<div data-testid="timer" style={timerStyles}>{Math.floor(gameState.timer / 1000)}</div>
			<div class="word" data-testid="scrambled-word" id="scambled">{gameState.currentShuffle}</div>
			<input id="gameInput" type="text" bind:value={gameState.guess} autofocus />
			<CompletedWords correctWords={gameState.correctWords} correctCount={gameState.correctCount} />
		{:else if gameState.state == 'end'}
			<h3>Thanks for playing. Come back tomorrow for another puzzle!</h3>
			<GameEnd
				correctCount={gameState.correctCount}
				correctWords={gameState.correctWords}
				lastWord={gameState.lastWord}
				todaysPuzzle={todaysPuzzle}
			/>
		{:else if lastPuzzle == todaysDate}
			<h3>Thanks for playing. Come back tomorrow for another puzzle!</h3>
			<GameEnd correctCount={lastCorrect} correctWords={lastWords} {lastWord} todaysPuzzle={todaysPuzzle}/>
		{/if}
	</div>
</div>

Storing Game Results

After a game is played, we make a POST request to Turso in order to store the game result. These are stored anonymously, and really just a tool for me to track number of games played, and analyze the difficulty. If no one is getting it ‘perfect’, then I want to change it up. I want the feeling of getting a ‘perfect’ game to be satisfying, but not impossible. I’m using their HTTP request endpoint, which feels insecure, but this data isn’t critical so I’ll roll with it.

We also have a quick endpoint to get a count of all played games which I’m rendering in the footer of the game.

./src/lib/db.ts
export const storeGame = (
	category: string,
	correctCount: number,
	totalWords: number,
	words: string[][],
	correctWords: string[],
	completeGame: boolean,
	timeToComplete: number
) => {
	fetch(url, {
		method: 'POST',
		headers: {
			Authorization: `Bearer ${authToken}`,
			'Content-Type': 'application/json'
		},
		body: JSON.stringify({
			requests: [{ type: 'execute', stmt: { sql: `INSERT INTO games (category, correctCount, totalWords, words, correctWords, completeGame, timeToComplete) VALUES ("${category}", ${correctCount}, ${totalWords}, '{${words}}', '{${correctWords}}', ${completeGame}, ${timeToComplete});` } }, { type: 'close' }]
		})
	})
		.then((res) => res.json())
		.then((data) => console.log(data))
		.catch((err) => console.log(err));
};

export const getRunCount = async () => {
	const data = await fetch(url, {
		method: 'POST',
		headers: {
			Authorization: `Bearer ${authToken}`,
			'Content-Type': 'application/json'
		},
		body: JSON.stringify({
			requests: [{ type: 'execute', stmt: { sql: 'SELECT count(*) FROM games' } }, { type: 'close' }]
		})
	});
	return data.json();
}

Testing

I trusted AI too much when building this game, and we’ve got to run a few tests to quickly validate the puzzles I had AI generate. I have all of the puzzles stored in their own object, which is ugly but simple. We’ll run the critical unit test that will validate all puzzles are correct and usable. AI was putting in extra characters in the scrambled word on occasion, or omitting words. So we validate that the word and its scrambled pair are the same length, then we sort the characters to validate they contain the same letters.

describe('Validate all puzzles', () => {
	Object.keys(puzzles).forEach((date) => {
		it(`validates ${date}`, () => {
			puzzles[date].forEach((puzzle) => {
				console.log(puzzle);
				expect(puzzle[0].length).toBe(puzzle[1].length);
				let wordLetters = puzzle[0].split('').sort();
				let scrambledLetters = puzzle[1].split('').sort();
				console.log(wordLetters, scrambledLetters)
				expect(wordLetters.toString() === scrambledLetters.toString()).toBe(true);
			});
		});
	});
});

And with those validated, we can let Playwright ‘play’ the game. I wanted to write a test that emulated a real player as simply as possible. My solution was pretty simple. I collect the scrambled word from the screen. Then, I loop through 5 times, each time I scramble the scrambled word string again, make sure it isn’t the actual solution, then enter it. After these 5 attempts, I enter the correct solution and repeat for all of the words. Then I validate the results page displays the correct results. And then we let Playwright fail, and validate the results page displays the expected details.

./tests/basic-gameplay.spec.ts
import { test, expect } from '@playwright/test';
import { getToday } from '../src/lib/logic';
import { puzzles } from '../src/lib/puzzles';

const tryToSolve = async (page, todaysPuzzle, index) => {
  console.log('Trying to solve', index);
	let scrambledWord = await page.getByTestId('scrambled-word').textContent();
  let guess = scramble(scrambledWord);
  for (let i = 0; i < 5; i++){
    guess = scramble(scrambledWord);
    if(guess === todaysPuzzle[index][0]){
    } else {
      await page.locator('input').fill(guess);
      await page.waitForTimeout(500);
    }
  }
  await page.locator('input').fill(todaysPuzzle[index][0]);
  await page.waitForTimeout(500);
  if(index === todaysPuzzle.length - 1){
    console.log('puzzle solved, skipping');
  } else {
    expect(await page.getByTestId('scrambled-word')).toHaveText(todaysPuzzle[index+1][1]);
  }
    
};

const scramble = (word: string): string => {
  return word.split('').sort(function () {
    return 0.5 - Math.random();
  }).join('');
}

test('Solve the puzzle', async ({ page }) => {
  test.slow();
	const today = getToday();
	const todaysPuzzle = puzzles[today];
	await page.goto('/');
	await page.waitForTimeout(1000);
	await page.getByRole('button', { name: 'Play!' }).click();
  for (let i = 0; i < todaysPuzzle.length; i++){
    await tryToSolve(page, todaysPuzzle, i);
  }
  await page.waitForTimeout(1000);
  await expect(page.getByRole('heading', { name: 'Thanks for playing. Come back' })).toBeVisible();
  const correct = await page.getByTestId('correct-count').textContent();
  const total = await page.getByTestId('total-count').textContent();
  expect(correct == total).toBe(true);
});

test('Fail the puzzle', async ({ page }) => {
  test.slow();
	const today = getToday();
	const todaysPuzzle = puzzles[today];
	await page.goto('/');
	await page.waitForTimeout(1000);
	await page.getByRole('button', { name: 'Play!' }).click();
  for (let i = 0; i < todaysPuzzle.length-2; i++){
    await tryToSolve(page, todaysPuzzle, i);
  }
  const time = await page.getByTestId('timer').textContent();
  console.log('Skipping last two words, waiting for time to expire.');
  await page.waitForTimeout((parseInt(time) * 1000)+2000);
  await expect(page.getByRole('heading', { name: 'Thanks for playing. Come back' })).toBeVisible();
  const correct = await page.getByTestId('correct-count').textContent();
  const total = await page.getByTestId('total-count').textContent();
  expect(correct == total).toBe(false);
});

Demo Video

Wrapping Up

This game was really fun to build, and frankly really fun to write tests for. I’m going to leave it as is for right now, but moving forward, I’m planning to expand / refactor this. I’d love to try to re-build using HTMX and AlpineJS, for example. I’d love to write more robust testing to validate the localStorage and Turso requests. There is more to do here, but the game is playable and I’m going to leave that as it is for now. If I start seeing there are plenty of people playing, then sure, I’ll jump back in and expand.