All chapters · Chapter 8/9

Score and lives

Starting

Now you can lose. And you can win.

Text is a cropped image

A third file, hud.ts, and a font that isn't a system font:

const font = useLoadFont({
    json: '/hud_font.json',
    atlas: '/hud_font.png',
});

Two files. The PNG is an image with every letter drawn in a grid; the JSON says which coordinates each one sits at. The engine writes by cutting pieces out of that image and placing them one after another.

It's called a bitmap font and it's how absolutely all the text in arcade machines and consoles worked well into the 2000s. No curves, no anti-aliasing: each letter is a specific handful of pixels, which is exactly the look we're after.

This one in particular was drawn pixel by pixel for the tutorial, so you can copy it and use it wherever you like.

const score = createText({
    text: 'SCORE 0',
    font,
    style: { fontSize: 16 },
    transform: { x: 16, y: 14 },
    tint: getColor('#ede6f5'),
});

fontSize: 16 draws it 16 pixels tall, double the 8 it was drawn at. Since each letter is a rectangle of pixels, it multiplies in whole numbers and never goes blurry.

And changing the text later is just writing to it:

hud.score.text = `SCORE ${score}`;

Centring without guessing

const message = createText({
    text: '',
    font,
    style: { fontSize: 24 },
    anchor: { x: 0.5, y: 0 },
    transform: { x: width / 2, y: 200 },
    tint: getColor('#ff6b1a'),
    zIndex: 10,
});

anchor says which point of the text lands on x: 0.5 is halfway across it. With x in the middle of the screen, the text is centred however wide it is, so GAME OVER and YOU WIN both come out centred despite being different lengths, and changing the message is just changing its text. Nothing to measure. With x: 1 it's the right-hand edge, which is how LIVES sticks to the right corner without guessing how wide it is.

It goes at y: 200, in the empty band between the wall and the paddle, with zIndex: 10, which draws it over everything else. That matters: the HUD is made before the bricks, and without it whatever is made later would be painted over the message.

Three variables and you're done

let score = 0;
let lives = 3;
let over = false;

Nothing fancier is needed. They live inside the scene, and because the scene body runs exactly once, useUpdate always sees them current: it's the same function, remembering its own surroundings.

Losing a life:

if (ball.transform.y > HEIGHT) {
    lives -= 1;
    hud.lives.text = `LIVES ${Math.max(lives, 0)}`;
    if (lives <= 0) {
        over = true;
        showMessage(hud, 'GAME OVER');
    } else {
        serve();
    }
}

And winning, which is one line:

if (bricks.length === 0) {
    over = true;
    showMessage(hud, 'YOU WIN');
}

Because every brick removes itself from bricks when broken, the empty list is the win condition. Nothing to count.

Freezing the game

useUpdate((dt) => {
    if (over) return;
    // ...
});

return leaves the function immediately. The loop still runs sixty times a second (you can't stop that), but it does nothing, so the last image stays there with the message over it.

It's the simplest way to end a match, and it's enough.

`let` or `const`: which one?

const is for what doesn't change:

const PADDLE_W = 64;

let is for what does:

let score = 0;
score += 100;   // this would be an error with const

Rule of thumb: write const every time, and switch to let only when the editor tells you that you can't reassign it. That way, seeing a let is a signal ("careful, this changes") rather than background noise.

One detail that trips people: const stops you reassigning the name, not modifying what's inside. Which is why paddle.transform.x += 5 works even though paddle is const: you aren't changing what paddle is, you're changing something of its own.

Your turn

Give the top rows more points. Start with one life. Change GAME OVER to something of your own, but remember the font only has capitals, digits and : - !.

And a real one, which you can probably do unaided: make winning award a thousand-point bonus.