Word puzzle game updates based on feedback

I thought we were done after the last post since the game was functional, had decent feel, and had exhausted the limits in which I wanted to do anything with it. But, well, there was sort of one common throughline in every one and every server that it happened to be shared in had, so I figured I should probably update the game to be a bit more intuitive in a few places.

And if we're updating the game, then there might be something worth blogging about. So. Here we are, another little dev journal for anyone interesting in how this game has beem put together can read. If you don't actually care, and just want to play the dang game. Then you can click right here to play.

If you do care, and if you've paid attention to the post before last, and the one linked in the first sentence of this post. You probably know what to expect, so, let's walk through some meandering thoughts and code! Trial and error, ahoy!

People like it! But…

The urge to do something like a word cloud of the feedback I got from the people I shared the word game with is high, but not enough for me to take the time to do it. So, I'll just tell you that the feedback was pretty positive. Which is great, it's nice when something one makes is liked. I'm also just glad that a few people seemed to get into it and have a lot of fun matching words, sending screenshots, and engaging other people in the "I'm stuck, hm, do you all see a word?" type conversations.

But there was one common problem most users had:

Words that should be able to be put in don't work. I spelled eel. Super normal word nothing crazy for example.
nice :D but wait a minute, how come this not work? maybe i didnt get the rules lol

Pretty much everyone interprets the game's display to mean that they should be spelling any old word and trying to make words of the given length, rather than trying to find the specific words that are hidden. When they spell out a word that is definitely a real word, but isn't in the list, it frustrates them because they spelled a word. How come it doesn't give me the win!?

Is this surprising? Not really, the only instruction given to the user is on the start page after all, it just says "Click and drag to spell". Which isn't really much of an indicator that you're supposed to be spelling specific words and not anything that comes to mind. So, I think there's probably two ways to fix that.

Let's update the start screen

Last post we spent time making the victory screen go from a single piece of text, to a nice bouncy ball of dopamine. I don't think we neccesarily need to do anything too crazy with the start screen, but obviously, the single line instruction is not enough. So, let's refactor the start screen in the same way as we did the victory screen and then update it.

As always, the goal of any refactor is to keep the displayed behavior the same at first, then make it better once the internals are in a more pliable shape to meet our needs. So, let's grow a class for ourselves to do this. As a reminder, most of the work for the startup screen is in this code:

function setup_start() {
    const words = ['START'];
    const letters = letters_used(words);
    const matchState = new MatchState(words);

    const word_x = padding * 2;
    const origin_y = input_area_start + letter_box_width * 3;
    const origin_x = word_x + width / (letter_box_width * 6) + letter_box_width;
    const letterSelections = new StartSelect(origin_x, origin_y, letters, canvas);
    entities.length = 0;
    entities.push(letterSelections);

    // Create boxes, one for each letter in a word, show one word per row
    for (let i = 0; i < words.length; i++) {
        const word = words[i];
        const y = height / 3 + word_row_start + letter_box_height * i + i * padding;
        const wordRow = new WordRow(word_x, y, word);
        wordRow.subscribe(matchState);
        letterSelections.subscribe(wordRow);
        wordRow.draw = () => {};
        entities.push(wordRow);
    }
}

Confusingly, if you're looking at the setup code and wondering where the text "Click and drag to spell" is. It's nestled away inside of StartSelect which is uhm... Maybe not the best place for it and I can berate month-ago me for that poor choice. But, on the bright side, it makes for a great target for our first refactor!

First up, let's setup some boilerplate. The VictoryScreen from before makes for a good reference, but basically, we're going to setup the update and draw loops within the screen class like so:

export class StartScreen {
    constructor(canvas) {
        this.canvas = canvas;
        this.entities = [];
    }

    update(cursor, elaspedMillis) {
        for (let i = 0; i < this.entities.length; i++) {
            const entity = this.entities[i];
            if (entity.update) {
                entity.update(cursor, elaspedMillis);
            }
        }
    }

    draw(ctx) {
        for (let i = 0; i < this.entities.length; i++) {
            const entity = this.entities[i];
            if (entity.draw) {
                entity.draw(ctx);
            }
        }
    }
}

And then for now, create an instance of this useless class and put it into the main entity list that the page is keeping track of:

function setup_start() {
    ...
    entities.push(new StartScreen(canvas));
}

After updating my ui.js file to export the class, the main page simply imports it from the ui module without any trouble.

import { StartScreen } from 'ui';

Great. We've accomplished nothing, let's change that:

import { START_SELECT_STYLE } from 'ui';

class CenteredText {
    constructor(x, y, text) {
        this.x = x;
        this.y = y;
        this.text = text;
    }

    draw(ctx) {
        ctx.save();
        ctx.fillStyle = START_SELECT_STYLE.FONT.FILL;
        ctx.font = "36px serif";
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText(this.text, this.x, this.y);
        ctx.restore();
    }
}

Is this overkill? An entire class to represent a single line of text on the screen?

Only if it stays that way! And, obviously, it's not going to. I think we can do something, similar, yet slightly different as the victory text in order to give the title screen a nice header of sorts, and then put the regular instruction text underneath it. I'm hoping it will look good. But first, we need to confirm the new screen class is working, so, in the StartScreen constructor:

export class StartScreen {
    ...
    constructor(canvas) {
        this.canvas = canvas;
        const title_y = 610;
        const title_x = canvas.width / 2;
        this.entities = [
            new CenteredText(title_x, title_y - canvas.height / 2, "Click and drag to spell")
        ];
    }

You might be asking, where does 610 come from? Well, the index page has a bunch of random constants for placement

const padding = 10;
const width = 450;
const height = 800;
const word_row_start = padding * 2;
const letter_box_height = 50;
const letter_box_width = 50;
const input_area_start = word_row_start + letter_box_height * 7 + padding * 7 + padding * 2;

I feel like I almost always do some variation of the above. And, well, it makes me wish I just had Java's layout managers for Swing or something. Because often times, the relative offsets are useful, but it feels like it expands more and more as other components are added in and by the end of it, I can't actually keep track of the different values and variables to use and I end up with unrelated variables having influences on each other.

So, to avoid that, I just collapsed the calculation to the value itself. So, title_y = 610 it was! Lastly, we can go to StartSelect and comment out the line rendering that text:

ctx.fillText("
    Click and drag to spell", 
    this.canvas.width / 2, 
    this.y - this.canvas.height / 2
);

And then we should see nothing change! How anticlimatic, but that's a solid refactor for you. Simple code gets simple updates after all. And so, with out newfound powers, we can move this instructional text down, and introduce a title card. Title card? Is that what one calls this?

I've spoiled you a bit on what we're about to make happen. But given that this post was never supposed to happen, hopefully you forgive me. As you can see, we've added in the name of the game1 as well as elaborated slightly on what one should be doing when playing. I'm not actually sure if this is clearer, but my hope is that by adding the word "guess" in there, it carries the weight that a user isn't supposed to make any arbitrary word.

I'm probably wrong on this. But we'll see, I think one of the other updates we'll do in this blog post will help out a lot with that complaint anyway. The instruction text is just re-using the class we just made:

this.entities = [
    new GameTitle(title_x, title_y, "Puzzle Trie"),
    new CenteredText(title_x, title_y, "Guess the words!"),
    new CenteredText(title_x, title_y + 60, "Click and drag to spell"),
];

And I've created a new GameTitle class for the fancy shining title header card thing. We need to update the color constants to include the FONT.STROKE definition, which is pretty simple:

export const START_SELECT_STYLE = {
    ...
    FONT: {
        FILL: FONT_COLOR,
        STROKE: FONT_COLOR_INVERT
    },
};

This will give us the color for the sheen of the animated gradient rolling across the heading. To actually make that gradient, we can pretty much copy and past the code we used for the VictoryScreen last time. But we'll tweak it a bit so that we don't have a bunch of constants for the specific angles for each letter. Instead, let's autogenerate those positions:

const letterAngles = [];
for (let i = 0; i < this.text.length; i++) {
    const boundaryLeft = Math.PI * 3.0/4.0;
    const boundaryRight = Math.PI * 1.0/4.0;
    const angleToBeDrawnAt = lerp(boundaryLeft, boundaryRight, (i / (this.text.length - 1) ));
    letterAngles.push(angleToBeDrawnAt);
}

For reference, this will probably help understand what we're doing here:

This handy angle chart is a great way to see that three Pi over four, and Pi over four are the angles 135 and 45 degrees. Which I think gives the best slope for the letters. I tried out 120/60 and 150 and 30 but they didn't look particularly good. Either way, we're linearly interpretting between two positions on the unit circle, then dividing the whole arc up based on the letter's position in the string of text we're rendering.

I suppose I could also have not used the unit circle if I wanted a more elipsoidal arc, or if I just wanted to place the letters mostly horizontally with just a bit of lift. But anyway. We've basically subdivided up the specific angles in a circle so that our letters can get inserted in the right place. To actually draw them:

        
ctx.font = '48px Impact';
ctx.lineWidth = 4;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let i = 0; i < this.text.length; i++) {
    const letter = this.text[i];
    const wiggle = Math.sin(lerp(Math.PI, Math.PI * 2, this.timer.progressPercentage()) * 2);
    const angle = letterAngles[i];
    const placement = angle;
    const radius = 250;

    const textAnimationPercentage = this.textTimer.progressPercentage();
    const gradient = ctx.createLinearGradient(
        this.x, this.y, 
        this.x + radius, this.y - radius * 2
    );
    gradient.addColorStop(0, START_SELECT_STYLE.FONT.FILL);
    if (0.1 < textAnimationPercentage && textAnimationPercentage < 0.90) {
        gradient.addColorStop(textAnimationPercentage - 0.1, START_SELECT_STYLE.FONT.FILL);
        gradient.addColorStop(textAnimationPercentage, START_SELECT_STYLE.FONT.STROKE);
        gradient.addColorStop(textAnimationPercentage + 0.1, START_SELECT_STYLE.FONT.FILL);
    }
    gradient.addColorStop(1, START_SELECT_STYLE.FONT.FILL);
    ctx.fillStyle = gradient;

    ctx.strokeStyle = START_SELECT_STYLE.FONT.STROKE;
    ctx.fillText(
        letter,
        this.x + Math.cos(placement) * radius,
        this.y - Math.sin(placement) * radius + wiggle
    );
}

You'll notice that this is nearly identical to the VictoryScreen code besides the fact that it doesn't have a long list of tuple-like constants for the angles. I could probably go and refactor the VictoryScreen code to use something similar to this. But given that we've only got 2 of these arched text pieces, I don't think it's worth the effort since it won't be re-used.

What could be re-used though is this shining graphical effect. We've used that 3 times now. The WordRow, VictoryScreen, and now StartScreen. It's also kind of gross to look at. Or well, to me it sort of feels like a wall of noise that my eyes glaze over when I'm reading the body of the code. And it feels like the combination of timer + gradient is something that could easily be pulled out. The main trouble of course is the fact that the gradient has an x and y point, can be linear or radial, and that just sort of couples it a bit.

Would we do something like this?

new MovingGradient(cx, cy, size, color1, color2)

Or perhaps have that as well as

new RadialMovingGradient(cx, cy, size, color1, color2)

Since we also used the gradient trick for the way the LetterSelect reacts to matches and misses? Maybe? But... this is yakshaving and I think it's best if we leave it alone. Let's move on to more fruitful pursuits that will make our gamers less confused!

You get a point yes, but

I think, beyond the fact that the game didn't really tell you what you were supposed to be doing 2 it also doesn't really give you more than an error noise/visual on the wrong word. And, well, it'd be nice if you still got something for making a word from the letters given to you. So I think we have to deal with the one feature I've been putting off this enter time.

A scoring system.

Obviously, guessing the right word should be worth the most points. But guessing "bonus" words, like most users tended to do, should maybe count for something as well. Should they count for more or less? I'd argue for less. It's like if I asked you to bring me a few apples and you brought me two apples and a pineapple. A funny joke and a bit of wordplay, but not what I asked for. So, ding, lower marks for you on this test.

The hardest part about this to me is, well... where do we put it?

Scoring is normally out of the way I think. So my first instinct is that it should be at the top somewhere. But, our rows for the letter boxes take up the entire width of the container and I'm not about to try to arbitrarily always force the first word to be under a certain length. That leaves us with the left hand space next to our usual letter select.

So, what does putting a little pill button with space for numbers result in?

Not great. Not terrible when we have less than 6 words, but when we do? It's right on the edge, which is why we moved the mute button to the other side. Of course we can't do that this time, so, maybe we take a different tactic. I don't really love that it's a button either. What is it supposed to do when its clicked? What about using our new CenteredText class to drop out the background, will that asymmetry look bad or good?

Eh... I was thinking just the number would be good but it sort of feels off with no background that way, so... what about

Is score good? Or would it be better instead to say what we're counting, like:

It still feels sort of… wrong. Probably because the font is different than the buttons I think, so the visual balance of things feels off. So, lets tweak the centered text class around a bit. From its basic:

export class CenteredText {
    constructor(x, y, text) {
        this.x = x;
        this.y = y;
        this.text = text;
    }

    draw(ctx) {
        ctx.save();
        ctx.fillStyle = START_SELECT_STYLE.FONT.FILL;
        ctx.font = "36px serif";
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText(this.text, this.x, this.y);
        ctx.restore();
    }
}

To the slightly refactored:

export class CenteredText {
    constructor(x, y, text) {
        this.x = x;
        this.y = y;
        this.text = text;
        this.font = '36px serif';
    }

    getFont() {
        return this.font;
    }

    setFont(newFont) {
        this.font = newFont;
    }

    draw(ctx) {
        ctx.save();
        ctx.fillStyle = START_SELECT_STYLE.FONT.FILL;
        ctx.font = this.getFont();
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText(this.text, this.x, this.y);
        ctx.restore();
    }
}

With this, I can now set the font for this score text to match the font of the pill buttons with a quick and simple scoreButton.setFont("48px serif"); and then shift some numbers around annnnnd:

This feels claustrophobic. Let's shift back to just the numbers:

It still feels pretty bare bones. Doesn't indicate what's being counted (though that would become obvious pretty quickly), and just doesn't really match much. Fiddling around with this type of stuff, thinking about shifting the entire UI around to try to make things clear or similar doesn't really spark joy in me. So, let's try one more idea before we go down the path of trying to make this text have a background behind it.

What if I put it in the center of the text select?

I kind of think that if I didnt' show this until they make a match or word... that this might feel good. In order to test that idea we'll need to actually do the scoring system though. Let's start with the basic, correct words have been guessed type of score. In my index file, where we've got all the other global stuff, let's add the text and score variables:

const origin_y = input_area_start + letter_box_width * 3 - 50;
const origin_x = width / 2;
let score = -1; // -1 because matching START makes a match
const scoreButton = new CenteredText(origin_x, origin_y, '00');
scoreButton.update = (cursor, elaspedMilli) => {
    if (score === 0) {
        scoreButton.setText('');
    } else {
        scoreButton.setText(score);
    }
}
scoreButton.setFont("48px serif");
...

class MatchState {
    on_match(name, word) {
        if (name !== EVENT_NAME.MATCHED) {
            return;
        }

        if (this.words_to_find.includes(word)) {
            this.found_words.add(word);
            score++;
            document.dispatchEvent(new Event(SOUND_EVENTS.YES_MATCH));
        }
...
setup_entities() {
    ...
    entities.push(scoreButton);
}

… Yeah that works. It actually looks pretty nice the way it appears after the radial impact of the match. My immediate other thought is that it's too static though. Like, I want it to feel like it changed, not just instantly change. Before we try to polish it up a bit though, I think I'd like to update the code so that if someone made a word, they get a point. Though... we can't have people getting points for a word they already entered (that isn't part of the current round).

For a fast lookup in our dictionary we can of course, use our dictionary we've got. And also, we can insert into an empty Trie to keep track of words we've already found so that people can't make the score go up just by spelling the same word over and over again. There's only one problem. We never implemented a contains method in the Trie. Funny, we only needed random walks and insertion up until this point. Let's code up a contains method real quick:

contains(word) {
    if (!word || word.length === 0) {
        // Don't think need to call this a terminal either.
        return false;
    }
    
    let tmp = this;
    for (let i = 0; i < word.length; i++) {
        const char = word[i];
        if (!tmp.children[char]) {
            return false;
        }
        tmp = tmp.children[char];
    }

    return tmp.isTerminal;
}

This works the same way as all the other traversal do, we look at each letter, check to see if it's in the current Trie or not, and if we finish traversing the letters and find a terminal node at the end, that means that it was a word in our trie already and we can return true. If it's not a terminal node, then we're not at a full word and so we'd return false. Given that this is intrinsically tied to whether or not the last node is a terminal, we can simply return that boolean flag as the actual result.

Next up, where do we put this code? Well. We can't just put it in on_match because a word that isn't in the chosen set won't ever trigger this code! But, our LetterSelect class has another useful method on_select, which is fired whenever we release the mouse button and letters have been selected. This seems like the perfect thing to listen to for our bonus words that aren't part of the actual round!

But, there's actually a better way to do this than that. on_match from the individual WordRows might not be that useful, but, on_match from the subscribers to the MatchState instance will get notified of both successful and failure matches. If we set up something to subscribe to just that, then we won't have to subscribe to multiple potential matchers or think about that. This should make things simpler. So let's transfer our global state into a listener object:

const origin_y = input_area_start + letter_box_width * 3 - 50;
const origin_x = width / 2;
class ScoreTracker {
    constructor(x, y, potentialWords) {
        this.scoreText = new CenteredText(x, y, '');
        this.scoreText.setFont("48px serif");
        this.foundWords = new TrieNode();
        this.score = 0;
        this.potentialWords = potentialWords;
    }
    update (cursor, elaspedMilli) {
        if (this.score === 0) {
            this.scoreText.setText('');
        } else {
            this.scoreText.setText(this.score);
        }
    }
    draw(ctx) {
        this.scoreText.draw(ctx);
    }
    on_match(name, word) {
        console.log(name, word);
        if (name === EVENT_NAME.NO_MATCH && this.potentialWords.contains(word)) {
            if (!this.foundWords.contains(word)) {
                this.score++;
            }
            this.foundWords.insert(word);
        } 
        if (name === EVENT_NAME.YES_MATCH) {
            // Do not insert into found words because we then we won't be able to find it in future rounds
            this.score++;
        }
    }
}
const scoreTracker = new ScoreTracker(origin_x, origin_y, potentialWords);
...
setup_entities() {
    ...
    matchState.subscribe(scoreTracker);
    entities.push(scoreTracker);
    ...
}

This has the added benefit that we're not monkeypatching the CenteredText with an anonymous update function, which I like. The other benefit is that when it comes to polish, or adding effects onto the text, or making text pop up or similar things like that. We've got a central place to do it! Lastly, since the tracker is setup during the setup_entities method (which comes after the start screen), we no longer need to do the -1 nonsense with the score's starting point. Win, win win.

The only issue of course is that I think we're still lacking in clarity to the user here. They get a point now, sure. But do they understand why nothing appeared in the word rows along the top part of the game? Nope. Not at all.

The one thing that comes to mind that could help make this apparent, but which is also probably a bit of a pain, would be to animate the letters flowing into the score or to the word row itself. Since that would visually show the user that they've got a point and it went into one of two buckets. The hard part of course is that our letters are fairly detached from their positions on the circle. So, we probably have our work cut out for us if we want to make it feel like the letters popped out of their existing locations.

Or... do we? The manager for these buttons, the LetterSelect has this method:

update(cursor, elaspedMillis) {
    ...
    if (this.check_words) {
        const word = this.active_buttons.map((b) => b.letter).join('');
        for (let i = 0; i < this.subscribers.length; i++) {
            this.subscribers[i].on_select(EVENT_NAME.SELECTED, word);
        }
        
        this.active_buttons.length = 0;
        this.check_words = false;
    }

There's no reason we couldn't pass the active button to the on_select as well, could we?

this.subscribers[i].on_select(
    EVENT_NAME.SELECTED,
    word, 
    this.active_buttons.slice(0, this.active_buttons.length)
);

If we pass that copy in, then we'll have the locations of each button that was selected! And with that...

// in ScoreTracker
on_select(name, word, active_buttons) {
    if (name !== EVENT_NAME.SELECTED) {
        return;
    }
    console.log(active_buttons);
}

// in setup_entities
letterSelections.subscribe(scoreTracker);

This makes it feel like maybe it won't be so tricky after all! Let's try out making a copy of the button, setting its destination to be the score indicator in the middle, and then making it disappear after it arrives. We can do this one of two ways, append the new buttons that will be flying around to the entities list globally, or have the score tracker keep track of them itself. Since it spawned them, I'm going to say that it makes sense to let it track them and handle cleanup:

constructor(...) {
    ...
    this.active_buttons = [];
}

on_select(name, word, active_buttons) {
    if (name !== EVENT_NAME.SELECTED) {
        return;
    }
    if (!this.potentialWords.contains(word)) {
        return;
    }

    for (let i = 0; i < active_buttons.length; i++) {
        const button = active_buttons[i];
        const copy = new LetterButton(button.x, button.y, button.letter);
        copy.setDestination(this.x, this.y);
        this.active_buttons.push(copy);
    }
    const myself = this;
    setTimeout(() => {
        myself.active_buttons.length = 0;
    }, 500);
}

If we update the draw and update methods to loop the active buttons and call the appropriate method on them, then we'll get to see this:

Now we've got two classes of match that are distinct visually. So, hopefully this helps the user build an intuition about the rules of the game here and what the title screen's text meant by "Guess the words!". I think that this could be made even more obvious by spawning a flashing banner that says BONUS or something like that. Maybe even do a pop up that says CORRECT GUESS on a word that matches the round's target words. But... it's a long weekend for me. Which means I have time, but I really want to play some video games.

Wrapping up (again)

Considering I didn't plan on circling back to this code for a long long time, writing another blog post on it barely a week later has me spinning. This all started off with wondering how one does the "drag and select" type mechanic for selecting the spelling of a letter, reading the MDN page on modules, and then getting fed up with my phone game's predatory ad practices. I didn't really think it'd end up as a four part blog post series.

But I'm glad it did. As I've said on stream before, the reason why I've picked up and started doing much much 3 longer posts when blogging is because I think the world needs more intermediate tutorials. The number of times one of my reports has told me "I want to learn X but everythings too basic or too advanced" is countable and I feel the same. It's easy to find a post that'll walk you through the most basic example of something, and then leave all the interesting parts as "exercises to the reader".

And yes, that IS how you learn. Figuring things out, putting the basics together, and running into weird bugs and errors as you go that teach you how to fix it next time. Or better yet, how to avoid making the same mistakes later on. But I think that there's a lot of programmers out there who struggle with coming up with ideas on what to code. So they look for tutorials, and then they're an expert on making TO-DO apps for some reason, but never use their brain to generalize further.

You need to go deeper.

So these posts, and these series of posts, are here to try to help bridge the gap a bit by not only giving code snippets of how to make something, but also including the thought process I have. What I'm considering. And yes, where I make mistakes and have to start making down payments on the tech debt I just created. So, all I'm really trying to say is, that you need to stop doing TODO apps and start doing reader exercises.

Now, really, if you want the game to have some fancy features. Clone it :) Get dissatisfied with the shoddy mess I've made and do something about it. Get mad! Make life take the lemons back! Despise the use of objects halfway? Go full OOP! Hate objects? Go full functional or imperative! Hate your browser and your users? Make it into an angular app and download megabytes of framework for no fucking reason onto your user's browser to suck up even more of their RAM! Ok, I jest, but hopefully you get the point.

Just go code stuff if you want to get better at coding. If you have no ideas, don't make a TODO app. Don't make something you've made before 4 Go play games or use a tool and ask yourself how they work and if you can make something similar. If the answer is no, then ask yourself why not. What else do you need to know before you can make that thing you wanted? Go do that. Take baby steps. Or, better yet, give it a shot even if you think you'll fail. You'll probably still learn something along the way.