Leave without saving?

Your editor changes will be lost.

Match-3-5 adds progression: score, level goals, a countdown, a HUD, and a game-over screen. The game starts directly in level one, and a successful level changes directly into the next PlayState.

Level State and UI

main.js starts the first level with transition props. PlayStateProps now carries the level number and the player’s cumulative score.

game.services.stateMachine.change(StateName.Play, {
level: 1,
score: 0,
});

When PlayState.enter() receives those props, it creates a matchless board, a PlayStats object, the UI panel, and the cursor. PlayStats groups the values that change throughout one level: level, score, scoreGoal, and time.

this.stats = new PlayStats(
props.level,
props.score,
this.calculateNewScoreGoal(props.level, props.score),
this.maxTime
);
this.userInterface = new UserInterface(this.stats);

The board moves right to make room for UserInterface, which renders the level, score, goal, and remaining time.

Score Calculation

Each matched tile is worth baseScore points. calculateScore() totals every tile in board.matches, then increases scoreTarget by that total.

let scoreAddition = 0;
this.board?.matches.forEach((match) => {
match.forEach((tile) => {
scoreAddition += this.baseScore;
});
});
this.scoreTarget += scoreAddition;

The displayed score tweens toward scoreTarget. Cascades can find another match before an earlier score tween finishes, so using the current displayed score as the next starting value would lose points. scoreTarget remains the authoritative destination while the UI catches up visually.

return this.game.timer.tweenAsync(
this,
{ score: this.scoreTarget },
0.75,
Easing.easeOutQuad
);

calculateMatches() starts the score tween, resolves the board, and then waits for both the score tween and the next cascade. This lets independent visual work run concurrently without allowing a state transition to outlive score animation.

Countdown Timer

startTimer() creates a repeating one-second timer task. It decrements the remaining time and plays a warning sound during the final five seconds.

this.countdownTask = this.game.timer.every(1, () => {
this.time--;
if (this.time <= 5) {
this.game.sounds.play(SoundName.Clock);
}
});

The returned TimerHandle is stored on the state. exit() cancels it before clearing props, so an old level’s task cannot continue decrementing time after the game changes state.

Win and Lose

Each new level requires an exponentially larger score increase. calculateNewScoreGoal() scales the base requirement by 1.5 ** (level - 1), adds it to the current cumulative score, and rounds the total to the nearest hundred.

const scoreRequired = this.baseScoreGoal * this.scoreGoalScale ** (level - 1);
return Math.round((score + scoreRequired) / 100) * 100;

shouldStateTransition() checks the timer first, then the score goal. On success, win() starts a new PlayState with the next level and current score. On timeout, lose() changes to GameOverState with the final score.

stateMachine.change(StateName.Play, {
level: level + 1,
score: this.score,
});

Game over and restart

GameOverState receives GameOverStateProps, renders the final score, and waits for Enter. Pressing Enter changes back to PlayState with level one and score zero.

stateMachine.change(StateName.Play, {
level: 1,
score: 0,
});

This keeps restart logic in the state that owns the game-over screen, while PlayState remains responsible for active level gameplay.

Powered by WebContainers
Files
Preparing Environment