Spielwerk 17: Bricks

2026-11-01
Spielwerk 17: Bricks - Teaser

Steer a paddle, keep the ball in play, clear the wall, and for the first time in this series you can hear it. The new toolbox module is AudioPlayer, because one audio element cannot play over itself and a browser will not play anything at all before the first click.

Play it · Source: collection-spielwerk/17-bricks · Toolbox 5.7.0 or later

The game

Paper breaks in one hit, wood in two, red in three, metal never. Where the ball hits the paddle sets its angle, and it speeds up slowly. Five walls ("Mauern") win the round, and seeded ones follow for as long as your three lives last.

New in the toolbox: AudioPlayer

AssetManager could always load audio. Playing it is the part with traps.

Why one audio element can't overlap

Give every sound one HTMLAudioElement and call play() on it, and a second hit doesn't add a second "bop": it cuts the first one off. A wall coming down sounds like a stutter. A new element per hit overlaps, but nothing limits it, and enough voices at once crackle.

The idea: channels

A fixed set of elements, eight by default. A new sound takes a channel that has rung out; if all eight ring, the oldest stops, because it is nearly over anyway. Music stands apart, one track at a time, under its own volume and mute, so a flurry of hits can never drop it.

export class AudioPlayer {
    public constructor(assets: AssetManager, options?: { sound_channels?: number });
    public play_sound(name: string, options?: { volume?: number }): void;
    public play_music(name: string, options?: { loop?: boolean }): void;
    public stop_music(): void;
    public resume_music(): void;
    public sound_volume: number;
    public music_volume: number;
    public is_sound_muted: boolean;
    public is_music_muted: boolean;
    public destroy(): void;
}

The core picks the channel. A free one that already holds this sound comes first, since it needn't load anything:

public play_sound(name: string, options: PlaySoundOptions = {}): void {
    const asset = this.find(name); // an unknown name warns once
    if (asset === null || this._is_sound_muted) {
        return;
    }
    const taken = this.free_channel(name) ?? this.new_channel() ?? this.oldest_channel();
    if (taken.name !== name) {
        taken.$audio.src = asset.url;
        taken.name = name;
    }
    taken.$audio.pause();
    taken.$audio.currentTime = 0;
    taken.volume = clamp_volume(options.volume ?? 1);
    this.sounds_played += 1;
    taken.order = this.sounds_played;
    this.apply_sound_settings(taken);
    this.start(taken.$audio).catch(() => {}); // a refused sound is gone
}

Muting differs on purpose. A muted sound doesn't start at all. Muted music runs on silently, so unmuting finds it where it would have been.

The autoplay rule

Browsers refuse to play sound until the visitor has clicked or typed on the page, by rejecting the promise play() returns. Left alone, that is an unhandled rejection on every hit. A refused hit is simply gone. A refused track stays wanted, and resume_music() tries again.

this.start($music).catch((error: unknown) => {
    const is_refused = error instanceof Error && error.name === "NotAllowedError";
    if (is_refused && this.$music === $music) {
        this._is_music_blocked = true;
    }
});

The check of the name matters: pause() during a pending play() rejects too, with AbortError, and a track stopped on purpose must not come back on the next click.

How the game uses it

Sound goes through the shell, the same way images do. The game lists its files and names its track, and the DOM-free game only reports what happened:

public override readonly music = 'spacy-bricks';

protected override update(game: Game, delta_seconds: number): boolean {
    const has_moved = game.update(delta_seconds);
    for (const event of game.take_events()) {
        this.properties.audio.play_sound(this.settings.event_sounds[event]);
    }
    return has_moved;
}

The shell keeps the rules a game would otherwise forget. Music starts when the intro is over, stops when the element disconnects, and comes back on a reconnect with the mutes as they were. With init-intro="false" no click comes before the music, so the shell retries a refused track on the next click or key.

Testing without ears

jsdom plays nothing, so the unit tests use fake elements and say what the browser does:

it("drops the oldest when all channels ring, and only that one", () => {
    player.play_sound("bip");
    player.play_sound("bop");
    player.play_sound("bip");
    const [oldest, middle, newest] = channels();
    player.play_sound("bop");
    expect(channels().length).toBe(3);
    expect(oldest.cuts).toBe(1);
    expect(middle.cuts).toBe(0);
    expect(newest.cuts).toBe(0);
});

The browser check goes further, since the sounds are inlined as data URLs. It spies on HTMLMediaElement.play, sees its promise reject with NotAllowedError before a real click and resolve after one, watches currentTime move, and puts an analyser on the track to find more than silence. Whether it sounds good is still a job for a person.

The module is src/audio/AudioPlayer.ts.

From earlier steps

Scaffold, start screen, intro and scaling are explained in Spielwerk 00: The workbench. message_card() moved there too, since Bricks would have been the fifth game to paint its own. One trap in the physics: a ball can settle into a level path or a loop between metal bricks, so it may never go flat, and ten bounces without a breakable brick knock it off course.

Embed it

<script src="https://www.game-object.de/tce-elements/tobidot/dll-tce-assets"></script>
<script src="https://www.game-object.de/tce-elements/tobidot/tce-bricks"></script>
<tce-bricks init-seed="4711"></tce-bricks>
Attribute Values Default
init-seed whole number; the first round, every next round is seed + 1 random
init-intro true / false; play the GO logo first true

Next

Previous: Spielwerk 16: Flummis · Next: Spielwerk 18: Rundenkampf · All steps

Comments

captcha