Timer code refactor ↩
As per usual, while working on the last post there were a couple things ruminating in the back of my mind as I found myself writing code. You may recall that we wrote code like this a few times:
constructor(...) { this.fadeInTimer = 0; this.maxAnimationTimeMs = 250; ... } update(cursor, elaspedMillis) { ... this.fadeInTimer += elaspedMillis; if (this.fadeInTimer > this.maxAnimationTimeMs) { this.fadeInTimer = this.maxAnimationTimeMs; } }
And I wasn't going to say anything since we only did this in WordRow
, LetterSelect
,
and in LetterButton
. But, a general timer tool would be pretty handy when it
comes to some of the other stuff we're going to tackle today. So let's think about what sort of timing
needs we have so far:
Class | Needs |
---|---|
WordRow |
|
LetterButton |
|
LetterSelect |
|
So there's obviously a lot of common ground. But there's also a pretty key difference.
Looping. The simple pulsing of the letter buttons we added in is a constant thing until
a user stops hovering or activating buttons. But the other two run once. Or at least,
WordRow
only ever runs once, and then LetterSelect
runs once,
but then is ready to run again the next time we try to do a selection. I think that these
last two cases can be handled as one unit. Since if you just never reset the clock then you've
got both. So. Let's make a little helper class and see if we can use it in our existing code
to prove out its worth before we use it for a few other things.
export class Timer { constructor(maxTime, loopEnabled) { this.accumulator = 0; this.maxTime = maxTime; this.loopEnabled = loopEnabled; } progressPercentage() { return this.accumulator / this.maxTime; } update(elaspedTime) { if (!this.accumulator) { this.accumulator = 0; } if (this.loopEnabled) { if (this.accumulator >= this.maxTime) { this.accumulator = 0; } } this.accumulator += elaspedTime; } reset() { this.accumulator = 0; } }
I wrote up this class looking at the table above, so let's see if it actually works as a replacement
for those three clases that need it. The best place to start is one immediately testable: LetterButton
constructor() { ... these two lines this.millis = 0; this.hoverAnimationPeriodMillis = 2000; ... become this this.timer = new Timer(2000, true); }
The radius
method using the calculated percentage from the two previous fields needs a tweak:
radius() { const radius = 29; if (this.state === BUTTON_STATE.HOVER) { const dx = sinusoidal(this.timer.progressPercentage()); const extension = 2; return radius + extension * dx; } if (this.state === BUTTON_STATE.ACTIVATED) { // dx was sinusoidal(this.millis / this.hoverAnimationPeriodMillis); const dx = sinusoidal(this.timer.progressPercentage()); const extension = 1.5; return radius + extension * dx; } return radius; }
And of course, for this to do anything and work as expected, we need the update method to update the timer:
// this update(cursor, elaspedMillis) { if (!this.millis) { this.millis = 0; } this.millis = (this.millis + elaspedMillis) % this.hoverAnimationPeriodMillis; ... // becomes this update(cursor, elaspedMillis) { this.timer.update(elaspedMillis);
Thankfully, this works without a problem:

But then again, it should definitely work for that one. Let's see if it works for LetterSelect
.
This should be a good check since unlike the LetterButton
it doesn't loop and requires us
using the reset method. Setting up:
// the initial code this.matchedAnimationTimer = 0; this.matchedAnimationTimerMaxMS = 500; this.matchedAnimationEnabled = false; this.matchedAnimationColor = 'white'; // becomes this.timer = new Timer(500, false); this.matchedAnimationEnabled = false; this.matchedAnimationColor = 'white';
And I'm pausing already. Staring at that matchedAnimationEnabled
field. I suppose we do already
have everything we need, in the sense that .reset()
can be called when we turn the timer off,
but I kind of dislike that because looking at the other code that'll change we end up with stuff like this:
on_match(eventName, word) { if (eventName === EVENT_NAME.MATCHED) { this.matchedAnimationEnabled = true; this.timer.reset(); this.matchedAnimationColor = 'white'; } if (eventName === EVENT_NAME.NO_MATCH) { this.matchedAnimationEnabled = true; this.timer.reset(); this.matchedAnimationColor = 'red'; } }
See how .reset() always comes when we tweak the enabled variable to true? The code in the update method is similar:
if (this.matchedAnimationEnabled) { this.timer.update(elaspedMillis); if (this.matchedAnimationTimer > this.matchedAnimationTimerMaxMS) { this.timer.reset(); this.matchedAnimationEnabled = false; } }
and in the draw method we have the same sort of guard before we use the percentage:
if (this.matchedAnimationEnabled) { // this we'll just be changing to do this.timer.getProgress() const percentage = this.matchedAnimationTimer / this.matchedAnimationTimerMaxMS % 1.0;
The fact that these two things are always paired together makes me wonder if it would make sense to push the "enabled" state itself into the timer. After all, I was sort of thinking about that before and the reset functionality is part of that. It makes intuitive sense to me that you could enable a timer and disable it to prevent it from accumulating for a while. The point of making the timer class isn't to jam in everything of course, it's a utility we're going to use for something else, but... staring at the class itself for a bit, I'm starting to feel like it makes sense to do this. Like, if you were to pause the game, being able to pause the timers would be good.
I've convinced myself I suppose. So, updating the timer to have an enabled
field that's
set to true by default, and then tweaking the update
method for the timer to return if
the timer isn't enabled. Also, I've fixed a bit of an error when loops are disabled, we shouldn't
just let the accumulator
build up time, it should cap out at the max.
update(elaspedTime) { if (!this.enabled) { return; } if (!this.accumulator) { this.accumulator = 0; } if (this.loopEnabled) { if (this.accumulator >= this.maxTime) { this.accumulator = 0; } } else { if (this.accumulator >= this.maxTime) { this.accumulator = this.maxTime; } } this.accumulator += elaspedTime; }
Or, should it? Should we automatically disable the timer if it's not looping and it hits the max time?
Is that really the same thing as being disabled? Disabled, versus "Done" feels different. Staring at
the update
method in the LetterSelect
for where we'd tweak the usage:
if (this.timer.enabled()) { this.timer.update(elaspedMillis); if (this.timer.progressPercentage() === 1.0) { this.timer.reset(); this.timer.disable(); } }
What do I gain from pushing the enabled state into the timer? And am I just overcomplicating things here? Or are these questions to be solved now? I imagine that anyone reading this has probably had similar feelings before while working on something. One of those, Malcolm in the middle gif moments, where your mind is like the Dad running around to hardware stores and such to try to fix the lightbulb but ends up fixing his car's tire instead.

Ahem. So anyway, I've had a night or two to sleep on this (work was busy and my mental capacity was low after hours, so I've just been playing Xenoblade a lot) and realized that I'm being a bit silly. Yes, I'm being silly. If the timer isn't set to looping mode, and it reaches the end, then it is at 100% and should stay there for any UI elements or downstream interested parties to use. Why the heck was I thinking about about if the timer should reset or not? Eh? Huh?

Anyway. While I could certainly edit all of the above and make it look like my plan was flawless from the start, I don't think that's actually interesting to anyone reading this. If I'm going to learn from my mistakes, I want you to as well just in case you were right there with me nodding along as I started walking off a cliff. So, codingwise, I want to add one more little helper to the timer:
maxTimeReached() { return this.progressPercentage() >= 1.0; }
And then use that to tweak the LetterSelect
code to enable and disable the timer as expected:
// in update() if (this.timer.isEnabled()) { this.timer.update(elaspedMillis); if (this.timer.maxTimeReached()) { this.timer.reset(); this.timer.disable(); } } // in draw() if (this.timer.isEnabled()) { const percentage = this.timer.progressPercentage(); const gradient = ctx.createRadialGradient( this.x, this.y, this.backgroundRadius() * percentage, this.x, this.y, this.backgroundRadius() ); ...
Does it work?
LetterSelect.js:97 Uncaught TypeError: Failed to execute 'createRadialGradient' on 'CanvasRenderingContext2D': The provided double value is non-finite. at LetterSelect.draw (LetterSelect.js:97:34) at index.html:133:39 at Array.forEach (<anonymous>) at draw_entities (index.html:133:22) at game_loop (index.html:113:13) at HTMLDocument.<anonymous> (index.html:259:13) at HTMLDocument.startMenuNewGameHandler (index.html:243:22) at MatchState.on_match (index.html:162:30) at WordRow.on_select (WordRow.js:33:41) at StartSelect.update (StartSelect.js:83:37)
Nope. Because... the elapsed time is occasionally NaN. Which is then added to the accumulator and then
divided, which causes the progressPercentage
to be NaN and the gradient to explode. Well.
At least it's an easy to fix problem. We can just ignore any updates that have nonsensical elaspedTime
arguments from within the Timer
's update method like so:
if (isNaN(elaspedTime)) { return; }

And we're back in business. Though... it appears I've found a minor bug. Notice how we see the
"You Win" message, and then afterwards the white pulse of a matched letter? That's a bit odd.
Since, well, LetterSelect
should have been replaced by a new entity since when we
start a new round we clear out the list that's running those updates and replace it with the
victory screen for a bit:
const victory = new VictoryScreen(canvas); entities.length = 0; entities.push(victory);
And then setup_entities
is called after an entire second sitting on that screen.
So... Where's the on_match
event coming from that's triggering this then? Well.
Remember in the constructor of Timer
we do this?
constructor(maxTime, loopEnabled) { this.accumulator = 0; this.maxTime = maxTime; this.loopEnabled = loopEnabled; this.enabled = true; }
Yeah. That last line. I didn't stop and think about it. Of course the timer should be enabled when you make it! Why wouldn't it be? And, honestly, whenever I've booted up the game and dragged over the S T A R T buttons, it's been doing this gradiant pulse. But it felt good so I didn't even think twice about it.
So that's a bug as a feature!
With that, I can say that LetterSelect
is refactored and happy. So just the other,
slightly easier case, to deal with then: WordRow
. Much like LetterButton
it only has the two variables tracking its state:
this.fadeInTimer = 0; this.maxAnimationTimeMs = 250;
So of course, both of those can be replaced with this.timer = new Timer(250, false);
Then move along to its draw
method, where we've got the usual replacement going on:
// const percentage = this.fadeInTimer / this.maxAnimationTimeMs; const percentage = this.timer.progressPercentage();
And in the update
method
this.fadeInTimer += elaspedMillis; if (this.fadeInTimer > this.maxAnimationTimeMs) { this.fadeInTimer = this.maxAnimationTimeMs; } // becomes this.timer.update(elaspedMilli);
Which is a nice tidy update and then we can remove the two values from the class since they're unused anywhere else at this point. Right? Well. Erm. No, we've broken something again:

WordRow.js:67 Uncaught IndexSizeError: Failed to execute 'addColorStop' on 'CanvasGradient': The provided value (1.0668) is outside the range (0.0, 1.0). at WordRow.draw (WordRow.js:67:30) at index.html:133:39 at Array.forEach (<anonymous>) at draw_entities (index.html:133:22) at game_loop (index.html:113:13)
Well. We've kind of dealt with this one before in the last post. Only that time it was because our function to do the animation logic was bouncing outside the range. This time, it's our accumulated value of elapsed time. Which is then reflected in the progress percentage itself by the code:
const percentage = this.timer.progressPercentage(); if (percentage !== 1.0) { gradient.addColorStop(percentage - percentage / (j + 1), WORD_ROW_STYLE.LETTER.FILL); gradient.addColorStop(percentage, WORD_ROW_STYLE.LETTER.STROKE); gradient.addColorStop(1.0, "white"); ctx.fillStyle = gradient; } else { ctx.fillStyle = WORD_ROW_STYLE.LETTER.FILL; }
Well, percentage surely isn't 1.0 when it's 1.0664000000000013. So, we can avoid the exact match and tweak it to do the gradient related color stop fun only when the percentage is actually within the range that such things support. Which I suppose would have been smart to do before. We're learning!
if (0 <= percentage && percentage < 1.0)
Lovely! So this works 1 and we're free to move along to our next step with our handy dandy timer class at the ready!
A proper victory screen
The most lackluster thing about the game right now is that it just says "you win" then dumps you back into a new round after a brief moment. This is all well and good for prototyping, but I think we should do two things:
- Put a pause before we move to the new screen
- Update the victory screen to need user input to go to the next round
That first item is why I wanted to make our little Timer class, since now we'll have more than 3 places that need this type of thing if we're going to be doing some animation, or nice things like that on the victory screen. Right now, the creation of the victory screen is just an instant clear of the entities and a push to the win screen:
function victoryOnAllMatchHandler() { const victory = new VictoryScreen(canvas); entities.length = 0; entities.push(victory); setTimeout(() => { document.dispatchEvent(new Event(SCENE_EVENTS.ROUNDOVER)); }, 1000); }
To accomplish point one from above, all we have to do is not clear the entities right away. Then, do another timeout to let the win screen hang out for a moment.
function victoryOnAllMatchHandler() { const victory = new VictoryScreen(canvas); setTimeout(() => { entities.length = 0; entities.push(victory); setTimeout(() => { document.dispatchEvent(new Event(SCENE_EVENTS.ROUNDOVER)); }, 1000) }, 1000); }
The extra timeout is temporary until we do the second point from our TODO list for this section. So, let's get to cracking on the victory screen! The big question is: do we make a button like the Start button to continue? Or do we just make it a single click affair? Having the user spell out N E X T or A G A I N would be similar to the start button; but the start button is the way it is because it also acts as a short tutorial on what the game expects you to do.
With that in mind, I'd say we should make the next round button as frictionless as possible so that a user can easily
move on. With that in mind, our current VictoryScreen
is going to need some updates! Right now it's:
export class VictoryScreen { constructor(canvas) { this.canvas = canvas; } draw(ctx) { ctx.save(); ctx.font = '48px Impact'; ctx.fillText('You win', this.canvas.width / 2, this.canvas.height / 2); ctx.restore(); } }
So let's add an update
method to it so that we'll be able to pass that along to the child entities this will contain.
We'll need to do the same thing for the draw
method as well. And, while I'm at it, let's see what happens
if we try to use our LetterButton
for our next button too, we'll just place it somewhere random for now:
import { LetterButton } from "ui"; export class VictoryScreen { constructor(canvas) { this.canvas = canvas; this.entities = [ new LetterButton(this.canvas.width / 2, this.canvas.height - 200, 'Next') ]; } 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) { ctx.save(); ctx.font = '48px Impact'; ctx.fillText('You win', this.canvas.width / 2, this.canvas.height / 2); ctx.restore(); for (let i = 0; i < this.entities.length; i++) { const entity = this.entities[i]; if (entity.draw) { entity.draw(ctx); } } } }

Hm. I think the LetterButton
class, true to its name, really only wants a single
letter in it. My first instinct is to make a new class for the button that needs more than just
a single letter. But... stopping for a second, if basically the only difference is going to be
that we're just going to base the width on the number of letters it has anyway... is it possible
to do that without screwing up the existing letters?
I think it's going to definitely be a bit tricky, because my first instinct is to make a "pill" sort
of button, where we've got a long rectangle in the middle and then two half-circles on either side.
Right now, the LetterButton
uses just the radius()
method for its collision
detection as well as it drawing. So that'll throw a wrench into that idea pretty rapidly. So. Let's go
with my first instinct and make a new ui component. If after we finish it there's some shared DNA, then
we can refactor it at that point.
Putting pen to paper is simple enough. Let's get the idea of the "pill" box first:
// Length of the sides, not inclusive of the arc of the "pill" length() { return this.word.length * 50; }
Since we're using a 48pt font, I figure 50px should be enough for the character and then a tiny bit of
padding. In order to tell that the mouse is in the button, we can update the within
to look
at the left and right arcs of the pill, as well as a basic check against the rectangle in the middle:
within(mx, my) { // Left pill side if (this.x - this.radius() <= mx && mx <= this.x + this.radius()) { if (this.y - this.radius() <= my && my <= this.y + this.radius()) { return true; } } // middle piece const length = this.length(); if (this.x <= mx && mx <= this.x + length) { if (this.y - this.radius() <= my && my <= this.y + this.radius()) { return true; } } // right pill side if (this.x + length - this.radius() <= mx && mx <= this.x + length + this.radius()) { if (this.y - this. radius() <= my && my <= this.y + this.radius()) { return true; } } return false; }
Trust me, this looks far more complication than it actually is. There's no "height" in this, we're still using the radius for that. Think about it, we want this to be one continuous piece right? Then it follows that, if we were to draw the rectangle from its center point, then the radius, or, well, half of it, would extend upwards. Otherwise the circle and the rectangle wouldn't line up properly. To prove it to you, let's draw this:
draw(ctx) { ctx.save(); const style = this.state_color[this.state]; ctx.fillStyle = style.BACKGROUND.FILL; ctx.strokeStyle = style.BACKGROUND.FILL; ctx.font = "48px serif"; ctx.beginPath(); ctx.arc(this.x, this.y, this.radius(), 0, Math.PI * 2); ctx.arc(this.x + this.length(), this.y, this.radius(), 0, Math.PI * 2); ctx.rect(this.x, this.y - this.radius(), this.length(), this.radius() * 2) ctx.fill(); ctx.stroke(); ctx.fillStyle = style.FONT.FILL; ctx.strokeStyle = style.FONT.FILL; ctx.lineWidth = 4; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(this.word, this.x + this.length() / 2, this.y); ctx.restore(); }
If we set the strokeStyle
to not be the same as the fillStyle
we can see
the outlines of the individual shapes, it's pretty easy to see what's happening:

But, nix that and you've got what appears to be one solid button:

Is my estimate of 50px per letter far too much? Yes. But I don't hate the way it looks! So I'm going to keep it for now while we consider the question we asked before. Should this be its own button class, or does it share the same DNA?
The code I haven't shown you is the update
, radius
, subscriber
,
notify
, and the constructor
methods. Because, besides changing the word letter
to word
in the constructor arguments, they're all exactly the same! Does this mean
we should do some inheritance or something similar? Maybe. But we have two buttons in this
entire game. I'm not convinced we really need to be too worried about sharing these methods at the moment.
It would be better to put our efforts towards the rest of the
For one, we need to hook this next button up so that it triggers the new round, rather than the timeout
doing that. So, over in our double nested setTimeout
code from before, we can just delete
the second one so that we don't trigger it prematurely.
function victoryOnAllMatchHandler() { const victory = new VictoryScreen(canvas); setTimeout(() => { entities.length = 0; entities.push(victory); }, 1000); }
And then in VictoryScreen
we simply change the entity list to use the PillButton and
then subscribe ourselves to events from it. If we get a release from any button (because we only
have one) then we move along to the next screen via the SCENE_EVENTS.ROUNDOVER
constant.
export class VictoryScreen { constructor(canvas) { this.canvas = canvas; const nextButton = new PillButton( this.canvas.width / 2 - 100, this.canvas.height - 200, 'Next' ); nextButton.subscribe(this); this.entities = [ nextButton ]; } on_button(event, btn) { if (event === EVENT_NAME.RELEASED) { document.dispatchEvent(new Event(SCENE_EVENTS.ROUNDOVER)); } } ... }
With the next button, and the ability to wait for the user to interact to move onto the next round. Let's also make this lackluster "You win" text be more exciting. Right now it just looks like This:
Besides centering the text, let's also spruce it up a bit. My thought is that we should make letters curved around a center point, sort of like the letter select, but then animate them sliding into place if I can figure out how to do it. I think we're about to get into math, so, let's refer to this handy dandy angle chart

Given that we have 7 letters to display (counting the space) we can place the space's final position at π / 2 and then the other three letters at each of the 15 degree angles away from that and see how it feels. So. Without any AI assistance2
I sat down and did my best to code something up real quick and
Hm. Can you see where I went wrong?
class YouWin { constructor(centerPointX, centerPointY) { this.text = 'You Win'; this.timer = new Timer(1250, false); this.x = centerPointX; this.y = centerPointY; } update(cursor, elaspedMillis) { this.timer.update(elaspedMillis); } draw(ctx) { ctx.save(); let startRadian = Math.PI; const fromTo = [ [Math.PI, Math.PI * 3.0/4.0], [Math.PI, Math.PI * 2.0/3.0], [Math.PI, Math.PI * 7.0/12.0], [Math.PI, Math.PI * 1.0/2.0], [Math.PI, Math.PI * 5.0/12.0], [Math.PI, Math.PI * 1.0/3.0], [Math.PI, Math.PI * 1.0/4.0] ]; ctx.font = '48px Impact'; for (let i = 0; i < this.text.length; i++) { const letter = this.text[i]; const t = this.timer.progressPercentage(); const [from, to] = fromTo[i]; const placement = lerp(from, to, t); const radius = 100; ctx.fillText( letter, this.x + Math.cos(placement) * radius, this.y + Math.sin(placement) * radius ); } ctx.restore(); } }
Hint: It's not my angle placements. It's a factor of two things.
this.y + Math.sin(placement) * radius
Is wrong. Why? Because the canvas's Y value increases in the positive direction downward. So, if we flip that to a minus then:

The radius also needs to increase. But even doing so, we'll still have the other problem that I alluded to earlier:

We faced this when we set up the LetterSelect
as well. But the kerning is off because our
font is trying to render left aligned from the position we're giving it, and that position is along the
arc of the line where the center of the letter should be placed. Not the left anchor point.
Thankfully, the same style that fixed that can be used here:
draw(ctx) { ctx.save(); let startRadian = Math.PI; const fromTo = [ [Math.PI, Math.PI * 3.0/4.0], [Math.PI, Math.PI * 2.0/3.0], [Math.PI, Math.PI * 7.0/12.0], [Math.PI, Math.PI * 1.0/2.0], [Math.PI, Math.PI * 5.0/12.0], [Math.PI, Math.PI * 1.0/3.0], [Math.PI, Math.PI * 1.0/4.0] ]; 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 t = this.timer.progressPercentage(); const [from, to] = fromTo[i]; const placement = lerp(from, to, t); const radius = 200; ctx.fillText( letter, this.x + Math.cos(placement) * radius, this.y + Math.sin(placement) * radius ); } ctx.restore(); }
And this will place the letters in a more uniform way:

Is it perfect? Nah, not yet. But it's certainly better than it was I think. I think I'd like to try to make it so the letters aren't all bundled up at once and only appear as the other letter arrives at the position it should be. We could probably do some step-function-y type stuff... or we could see if just changing the opacity of each letter according to their lerp percentage does the trick.
ctx.globalAlpha = !fromTo[i - 1] ? 1 : placement < fromTo[i - 1][1] ? t : 0;

Eh... it's all a bit too fast. I think maybe what I should do is go back to that easing website and snag a bouncy function to use:
// https://easings.net/#easeOutBounce export function easeOutBounce(x){ const n1 = 7.5625; const d1 = 2.75; if (x < 1 / d1) { return n1 * x * x; } else if (x < 2 / d1) { return n1 * (x -= 1.5 / d1) * x + 0.75; } else if (x < 2.5 / d1) { return n1 * (x -= 2.25 / d1) * x + 0.9375; } else { return n1 * (x -= 2.625 / d1) * x + 0.984375; } }
And then updating the code to ditch the transparency thing and just tweak the percentage to become influenced by the ease-out:
for (let i = 0; i < this.text.length; i++) { const letter = this.text[i]; const t = easeOutBounce(this.timer.progressPercentage()); const [from, to] = fromTo[i]; const placement = lerp(from, to, t); const radius = 250; ctx.fillText( letter, this.x + Math.cos(placement) * radius, this.y + Math.sin(placement) * radius ); }
And the result?

I like it. We could add brighter colors, do that gradient fun that we did with the WordRow
maybe. Really, all we need to make this feel better is probably a score being shown in the center of the
invisible circle that we've arc-ed around. But that will have to come after we actually add in
a score. For now, let's just add a linear gradient because I think it looks cool. First off. Constants
for the colors (just black and white):
// in our colors.js file export const VICTORY_SCREEN_STYLE = { NEXT_BUTTON: { ... same as the letter select button ... }, YOU_WIN_TEXT: { FONT: { FILL: FONT_COLOR, STROKE: FONT_COLOR_INVERT } } };
Then we can add a new timer to the YouWin class in the constructor
this.textTimer = new Timer(3000, true);
Then in the draw
method loop, rather than having fillStyle
set to the FONT_COLOR
we change it instead to a gradient that moves along with the timer:
const textAnimationPercentage = this.textTimer.progressPercentage(); const gradient = ctx.createLinearGradient(this.x, this.y, this.x + radius, this.y - radius * 2); const STYLE = VICTORY_SCREEN_STYLE.YOU_WIN_TEXT; // this just here to format for this post gradient.addColorStop(0, STYLE.FONT.FILL); if (0.1 < textAnimationPercentage && textAnimationPercentage < 0.90) { gradient.addColorStop(textAnimationPercentage - 0.1, STYLE.FONT.FILL); gradient.addColorStop(textAnimationPercentage, STYLE.FONT.STROKE); gradient.addColorStop(textAnimationPercentage + 0.1, STYLE.FONT.FILL); } gradient.addColorStop(1, STYLE.FONT.FILL); ctx.fillStyle = gradient;
And the effect is pretty cool if I say so myself:

Great. Let's move on from shiny graphical florishes and to actual gameplay features!
Giving up on purpose
This should be a short one. Basically. Sometimes I just don't know what the heck the word is. Maybe it's too obscure. Maybe my brain's malfunctioning. Either way, I don't want to be sitting on this puzzle anymore and want a new one. And I'd like a button to give up quickly so I can get back to dopamine harvesting the easy words again.
Only question is... where do we put it? I suppose, a good place where it probably won't be clicked on by accident would be the bottom left of the screen. I'm biasing towards righthanded people here, but I think regardless, when you're holding your phone and tapping on it, the bottom two corners of the screen are mostly accident free.
So let's re-use our PillButton
and update setup_entities
in the index file
to set up the bomb button.
const giveUp = new PillButton(padding,canvas.height - 100, 'Give up'); entities.push(giveUp);

Wups. I think I need to reassess our width function. It was alright before with the next button, but this needs a bit of love I think.
length() { return this.word.length * 25; // Was 50 }
Doing this will offset the next button on the victory screen, but if we change
new PillButton(this.canvas.width / 2 - 100, this.canvas.height - 200, 'Next')
to
have only -50
instead of 100
it will center itself nicely and still
look okay. But... sort of sucks doesn't it? Having the width be a leaky abstraction like this?
I think if we make this button value configurable, rather than hardcoded to 50 or 25, then we'll
be able to handle this a bit more flexibly.
setLetterWidth(letterWidth) { this.letterWidth = letterWidth; } length() { return this.word.length * this.letterWidth; }
Then it's just a matter of defaulting it to either 25 or 50, then calling the setter in the
appropriate place. Then we can have our cake (wide button on victory screen) and eat it too!
(smaller button on main screen). For my purposes, I think the tighter feel should be the
default, so this.letterWidth = 25
goes in the constructor, and then in the
VictoryScreen
class, I just call nextButton.setLetterWidth(50)
and
things work out again.
In order to get room for the giveup button though, we need to shift the LetterSelect
up. There's no particular science to this. I just added a -50 in
const origin_y = input_area_start + letter_box_width * 3 - 50;
And then pushed the giveup button down by -50 instead of -100 to get it closer to the bottom of the screen:

It still feels a bit close. But I haven't tried playing the game too much with it here yet, so I'll leave it alone for now. I don't really mind that it's going off the side either. What I do mind though, is this:

I'm triggering that by just hovering over the give up button while selecting letters. Now, that
sounds like a poor user experience just waiting to happen. So, let's take a look at the state of things
again. The reason why this button is behaving like this is due to its state table still behaving the same
as a LetterButton
's, which are designed to stay active even if the cursor is no longer within
their boundaries.
You can imagine that the solution is pretty straightforward. Let's fight back against our copy-paste!
update(cursor, elaspedMillis) { this.timer.update(elaspedMillis); if (this.state === BUTTON_STATE.IDLE) { if (this.within(cursor.x, cursor.y)) { this.state = BUTTON_STATE.HOVER; } } else if (this.state === BUTTON_STATE.HOVER) { if (!this.within(cursor.x, cursor.y)) { this.state = BUTTON_STATE.IDLE; } else if (cursor.isDown()) { this.notify(EVENT_NAME.ACTIVATED, this); document.dispatchEvent(new Event(SOUND_EVENTS.ACTIVATE)); this.state = BUTTON_STATE.ACTIVATED; } } else if (this.state === BUTTON_STATE.ACTIVATED) { // this condition is now more precise: if (cursor.isUp() && this.within(cursor.x, cursor.y)) { this.state = BUTTON_STATE.IDLE; this.notify(EVENT_NAME.RELEASED, this); } // this is new vvvvv if (cursor.isDown() && !this.within(cursor.x, cursor.y)) { this.state = BUTTON_STATE.IDLE; } } }
As noted by the comments, we've tweak the logic when the button is in an ACTIVATED
state.
Rather than sending out a release event to everyone when the cursor is released (isUp). We now actually
check that the cursor is inside of our boundaries first. Something that you'd be doing normally anyway,
but not something we caught since all of our buttons3
were happy as the only buttons on their respective pages and it was all mostly fine.
With this update though, both the Next button on the Victory page, and our new Give Up button will respect the user's probable intent and not release a click event if the user isn't actively within their boundaries for the full duration of said click.

This is great. But we also still need to make this button actually give up. We've got two options. First,
we update the MatchState
to subscribe to the give up button and have it
// In setup_entities: const giveUp = new PillButton(padding,canvas.height - 50, 'Give up'); giveUp.named = 'giveup'; entities.push(giveUp); giveUp.subscribe(matchState); // in MatchState class on_button(event, button) { if (event === EVENT_NAME.RELEASED && button.named === 'giveup') { setup_entities(); } }
The ability to add random properties onto existing data in an adhoc way is one of the somewhat useful
properties of Javascript, which we can use to make sure that the on_button
handler doesn't
reset the game if it wasn't our specific button that was pressed.
But... Does it feel kind of gross to anyone else? It certainly works:

But it feels a tad hackey to me. So, our other option is to just make a dedicated listened for this and call it a day:
class GiveupListener { on_button(event, button) { if (event === EVENT_NAME.RELEASED) { setup_entities(); } } } // in setup_entities const giveUp = new PillButton(padding,canvas.height - 50, 'Give up'); const giveUpListener = new GiveupListener(); giveUp.subscribe(giveUpListener); entities.push(giveUp);
And this works just the same, but we haven't had to do anything that felt "hacky". Feels better to me to do
it this way right now. I do think that if I had a sort of GameScreen
class, similar to
the VictoryScreen
class, then it'd be simpler to do something like
this.giveup = new PillButton(); ... on_match(event, button) { if (button === this.giveup) { ... } }
But since we haven't done that. Let's set such thoughts aside. I want to add features today, and we spent the first part of this blog post refactoring so I'm itching to go! Speaking of, let's move onto our next new feature!
Shuffling letters
When you're trying to figure out what the words are. Sometimes you just end up running down the same pathways
over and over again. When that happens, sometimes it's nice to be able to rearrange say, C E A
into
the more familiar A C E
, or even E C A
, where if you walk backwards you can find the
word hidden in there.
So let's make a dedicated shuffle button. Pressing it will cause all the buttons to randomly re-arrange themselves on the letter selection panel. Potentially, this will help give a user a new perspective and they'll be able to figure out the pattern they were missing before.
const shuffle = new PillButton(canvas.width / 2 + 50, canvas.height - 50, 'Shuffle'); entities.push(shuffle);

This feels like it balances the game window out kind of nicely. We'll see if I agree or not whenever I
finally get around to playing this on my phone or not, but for now I'll let it sit in the bottom right
corner. Now we face the same problem as before. How do we actually get the LetterSelect
to shuffle its buttons around?
Let's update our list of possible events:
export const EVENT_NAME = { ACTIVATED: 'activated', RELEASED: 'released', SELECTED: 'selected', MATCHED: 'matched', NO_MATCH: 'nomatch', SHUFFLE: 'shuffle', // <--- NEW };
Then we can tell the letter select to listen for the shuffle event:
on_button(name, button) { if (name === EVENT_NAME.ACTIVATED) { this.active_buttons.push(button); } if (name === EVENT_NAME.RELEASED) { this.check_words = true; } else { this.check_words = false; } if (name === EVENT_NAME.SHUFFLE) { this.shuffleButtons(); } }
I'll leave that shuffleButtons
undefined for the moment, we'll come back there right
after we've wired things up. We basically need to adapt the activation button event into a shuffle
event. So, let's do that in a similar fashion to the GiveupListener:
class ShuffleListener { constructor() { this.subscribers = []; } subscribe(subscriber) { this.subscribers.push(subscriber); } on_button(event, button) { if (event === EVENT_NAME.ACTIVATED) { for (let i = 0; i < this.subscribers.length; i++) { const subscriber = this.subscribers[i]; subscriber.on_button(EVENT_NAME.SHUFFLE, this); } } } }
And in our setup_entities
method:
const shuffle = new PillButton(canvas.width / 2 + 50, canvas.height - 50, 'Shuffle'); const shuffleListener = new ShuffleListener(); shuffle.subscribe(shuffleListener); shuffleListener.subscribe(letterSelections); entities.push(shuffle);
Nothing to write home about here. The pill button will send its events over to the shuffle listener,
the shuffle listerner... listens... and then sends things along to the letterSelections
where our code is waiting to:
LetterSelect.js:48 Uncaught TypeError: this.shuffleButtons is not a function at LetterSelect.on_button (LetterSelect.js:48:18) at ShuffleListener.on_button (index.html:203:36) at PillButton.notify (PillButton.js:86:33) at PillButton.update (PillButton.js:69:22) at index.html:127:23 at Array.forEach (<anonymous>) at update_entities (index.html:125:22) at game_loop (index.html:112:13)
So now we face the question of "What does it mean to shuffle the buttons". Or,
moreso, how do we go about doing that? Well, if we take a peak at our setup code for
the LetterSelect
we set the x and y coordinates of each button during the
construction step:
const number_of_letters = letters.length; const radians_per_letter = (2 * Math.PI) / number_of_letters; const letter_box_width = 50; for (let i = 0; i < letters.length; i++) { const letter = letters[i]; const lx = x + Math.cos(radians_per_letter * i) * letter_box_width * 2; const ly = y + Math.sin(radians_per_letter * i) * letter_box_width * 2; const letterButton = new LetterButton(lx, ly, letter); letterButton.subscribe(this); this.buttons.push(letterButton); }
And then they're set in stone forever. So. That's the part we should tackle then! Let's make it
possible to change the position of the individual LetterButton
s.
setPosition(x, y) { this.x = x; this.y = y; }
And then with our newfound powers make the letters shuffle around:
shuffleList(list) { const coinFlip = () => Math.random() > 0.5; for (let i = 0; i < list.length; i++) { list.sort((a, b) => { return coinFlip() ? -1 : coinFlip() ? 1 : 0; }); } } shuffleButtons() { const number_of_letters = this.letters.length; const radians_per_letter = (2 * Math.PI) / number_of_letters; const letter_box_width = 50; this.shuffleList(this.buttons); for (let i = 0; i < this.buttons.length; i++) { const button = this.buttons[i]; const lx = this.x + Math.cos(radians_per_letter * i) * letter_box_width * 2; const ly = this.y + Math.sin(radians_per_letter * i) * letter_box_width * 2; button.setPosition(lx, ly); } }
Ignore the fact that I probably shouldn't be making a new flipping function every iteration. This is a pretty simple way to quickly re-order the list randomly. Though, it doesn't actually guarantee that it changes anything:

Especially when you've got a simple word like Inn
here and most of the letters are
the same and there's so few to move around. I suppose, if instead of doing random flips, if we
very purposefully picked pairs and performed swaps, then I think we'll have some vague
possibility to always getting something shuffled. Since I think the 3 letter options are probably
the most likely candidates of annoyance, let's just build in a special case for them:
shuffleList(list) { if (list.length === 3) { const first = list[0]; const second = list[1]; const third = list[2]; list[1] = first; list[2] = second; list[0] = third; return; } const coinFlip = () => Math.random() > 0.5; for (let i = 0; i < list.length; i++) { list.sort((a, b) => { return coinFlip() ? -1 : coinFlip() ? 1 : 0; }); } }
Part of me wonders if maybe a sort of subdividing algorithm would make sense for this. Like, if we just take subslices of any length array until we get one that's 3 long, do the shift of its elements, and then combine it all back together, would that work better than the coin flipping sort?
Maybe! But on the list of things that I find interesting to do, that's lower than the fact that we've been putting all this work into adding animation and visual feedback that the sudden swap for the letters with no animation at all feels wrong. So, rather than setting the position, let's set the destination
setDestination(x, y) { this.destinationX = x; this.destinationY = y; this.movingTimer = new Timer(500, false); } move(elaspedMillis) { this.movingTimer.update(elaspedMillis); this.x = lerp(this.x, this.destinationX, this.movingTimer.progressPercentage()); this.y = lerp(this.y, this.destinationY, this.movingTimer.progressPercentage()); } update(cursor, elaspedMillis) { this.timer.update(elaspedMillis); this.move(elaspedMillis); ...
See? Told you we'd be getting some major mileage out of the refactor we did today. Swapping
the shuffling method to call setDestination
instead of setPosition
results in a very nice animation if I do say so myself:

I think we've just got one more quality of life and one more gameplay mechanic to add and then we can call it a day.
A mute button
Is there anything more annoying than when there's no way to quickly and easily shut off the sound in a game? I'm not going to try to make a fullblown option screen or volume control since I don't have the patience for it right now and it's my word puzzle clone and you're all just along for the ride. If you want to allow the user to move or drag a slide around and stuff, by all means go for it. I'm just going to give a mute button and be done with it.
Since we've done this a few times, let's start backwards with the listener:
class MuteListener { on_button(event, button) { if (event === EVENT_NAME.ACTIVATED) { SOUNDS.activate.toggleMute(); SOUNDS.release_wrong.toggleMute(); SOUNDS.release_right.toggleMute(); SOUNDS.victory.toggleMute(); } } }
It's been a hot second since we originally
setup the sounds, so as a reminder. They're global! We use a single instance of our Sound
class
to control these, and they're all hooked up from the main index file to try to play on window events:
import { SOUNDS } from 'sound'; document.addEventListener(SOUND_EVENTS.ACTIVATE, () => { SOUNDS.activate.play(); });
They already have a canPlay
field since we have to wait until the sound is loaded to
attempt to play it, so we can easily implement mute with a simple toggle method:
export class Sound { constructor(path) { this.audio = new Audio(path); this.canPlay = false; const ref = this; this.audio.addEventListener("canplaythrough", (event) => { ref.canPlay = true; }); } play() { if (this.canPlay) { this.audio.play(); } } toggleMute() { // <--- this is new this.canPlay = !this.canPlay; } }
This means there is, technically, a bug, since if you're fast enough to hit mute before any
audio is ever loaded, then you'd be able to trigger the .play()
method despite
the event canplaythrough
not having been fired. But... I'm fairly doubtful that
this bug will ever happen in practice. Until proven otherwise (you will have to tweet me a
screenshot or something) I'm going to work under this assumption and finish off this feature
with another PillButton
// Mute button const mute = new PillButton(padding, canvas.height / 2, 'Mute'); const muteListener = new MuteListener(); mute.subscribe(muteListener); entities.push(mute);
This places the mute button above and to the left of the circle, though it's a tight squeeze:

Our options are to either find a new home for it, or reduce the number of potential words that we show to the user in each round. We could place it on the right, but with a sufficiently long word, then that'll end up still running into the squeeze.

Though, admittedly, it looks less offensive than I thought. And I really don't want to reduce the number of potential words for a user to find. So I'm kind of fine with it. And hey, I'm the programmer and product owner here! So let's leave it for now and if I run into problems the next time I'm on a bus or idling away the hours matching words, I'll decide to change it.
Wrap up
I had originally intended to include a section where we added a score to the game. With the plan to keep track of how many words you've gotten in a row without any false guesses, but this post is (according to an only word counter) about 50 minutes to read if you're not counting the source code snippets. So I imagine that you, dearest Reader, having made it this far, are a bit tired. Perhaps as tired as I am of the strange headaches and ear problems I've had of late.
There are other little side projects that have been occupying space in my mind as of late, and I can feel the itch to work on them building every word I write about this silly little Javascript project. It's been a lot of fun, and this whole thing was inspired simultaneously by randomly deciding to read about JS modules and by how scummy the amount of ads and poor UX experience a game I downloaded and was playing recently was.
I'd encourage anyone and everyone out there if you always find yourself saying "I want to code, but I don't have any ideas" to just make clones. Download a random game, try to make it. Take a stroll down retro-lane and play an NES or Gameboy game with fun mechanics that you like, pick out one of those and see if you can implement it. That's how this all started for me. The drag and select letters off a circle of buttons was interesting to me, and then the Trie ended up being even moreso. It's a lot of fun to see something on the surface and try to take a running jump to see if you can reach it.
So, this might be the last blogpost about the word puzzle for a while. I think I have a fix for the game not working as expected on my phone:
addEventListener("touchstart", (e) => { cursor.click = CLICK_STATE.DOWN; }); addEventListener("mousedown", (e) => { cursor.click = CLICK_STATE.DOWN; }); addEventListener("touchend", (e) => { cursor.click = CLICK_STATE.UP; }); addEventListener("mouseup", (e) => { cursor.click = CLICK_STATE.UP; });
But I won't know until I give it a shot. And that'll be after I upload the bundle of JS files up to neocities. Anyway, I hope you all enjoyed this, and maybe hopefully learned something or just enjoyed the ride. If you want to play the game. The links below.

Send bug reports to twitter or tell me when I'm streaming during the week. See ya!