Spielwerk 00: The workbench

2026-11-01
Spielwerk 00: The workbench - Teaser

Spielwerk is my series of small browser games. Every step is its own element on this site, built from the same template, and every step teaches my game library, the ts-game-toolbox, exactly one new module. This page is the workbench: the template all steps start from, and the toolbox module that does everything every game does the same way (the logo, fitting any window, input, the loop), so a step's code is only the game.

All steps · Source: collection-spielwerk/_template · Toolbox 5.7.0 or later

Three building blocks

The template

node scripts/new-step.mjs 01 zahlen-raten "Zahlen raten"

copies _template, renames the classes, links the toolbox, vendors the TCE SDK and the shared boot code, and installs. The result already runs (a bouncing ball and a restart button), and npm run check and npm run check:browser pass before a single line of game is written.

The code follows the spec's five-way split, and the toolbox provides a base class for each role:

The game itself lives DOM-free in src/game/ with tests in tests/. The template's whole element shell is 108 lines plus a 10-line entry point, and every one of them is about the bouncing ball. The browser check drives the built package through a page with two script tags, exactly as any other site would embed it.

New in the toolbox: the canvas game shell

Every game in this series opens with the GO logo, fits into any window, takes clicks and keys, and repaints on a throttled loop. The first version of this template did all of that by hand, in every step, and it was most of the code. Now it lives once in the toolbox: src/game-element/ has a base class for each of the five roles, and a step subclasses them and overrides hooks.

The element names its classes, and that is the whole file:

export class TceSwTemplate extends SpielwerkElement<TceSwTemplateLogic> {
    public readonly logic = this.assemble({
        settings: TceSwTemplateSettings,
        properties: TceSwTemplateProperties,
        renderer: TceSwTemplateRenderer,
        logic: TceSwTemplateLogic,
    });
}

SpielwerkElement is the one piece of glue to this site: it tells the shell where the logo comes from (dll-tce-assets) and retitles the desktop window on connect. It lives once, in collection-spielwerk/spielwerk-boot, and sdk:sync vendors it into every step as @spielwerk/boot, the same way it vendors the TCE SDK. The package's entry point is one call too, bind_spielwerk_package(), which takes the name, version and description from package.json, so they cannot drift from what the registry gets. The toolbox itself knows nothing about TCE.

What the shell does

CanvasGameLogic.start() runs on every connection and does, in order: read init-seed and init-intro once (and whatever read_attributes() adds), bind the input, attach the toolbox page to the stage, start the frame loop, create the first game, start the intro. stop() aborts one AbortSignal and everything above ends with it. The state survives a window being moved; only listeners die.

Seeds follow one rule in every step: game n of an element is created with init-seed + n. The same seed replays the same series, and "the third game of seed 4711" names exactly one game.

A game overrides what differs. Only create_game() is required. The type parameters name the game's own classes, so this.settings and this.properties have its fields without a cast:

export class TceSwTemplateLogic extends CanvasGameLogic<Game, TceSwTemplateSettings, TceSwTemplateProperties> {
    protected create_game(seed: number): Game {
        const { width, height } = this.settings.layouts[0];
        return new Game({ seed, width, height });
    }

    protected override on_start(): void {
        this.on_click(this.properties.restart_button, () => this.restart());
    }

    /** The ball moves on its own: every frame changes the screen. */
    protected override update(game: Game, delta_seconds: number): boolean {
        game.update(delta_seconds);
        return true;
    }

    /** A click on the scene that no button took, in layout coordinates. */
    protected override on_press(point: Vector2I): void {
        this.properties.game?.nudge(point);
    }

    protected override on_key_press(event: KeyboardEvent): void {
        if (event.key === ' ') {
            this.restart();
        }
    }
}

The hooks, all optional:

Widgets are created once in Properties and placed by a rule, not a rectangle; the shell re-places them whenever the stage switches layouts:

this.restart_button = this.place(new Button('Neustart', UNPLACED), (layout) => ({ left: layout.width - 184, top: 16, width: 160, height: 36 }));

Logic wires them with this.on_click(button, handler) or this.on_change(slider, handler); more under "The UI page" below.

The renderer implements one method. The shell has already cleared the canvas, set layout coordinates, and will draw the toolbox UI on top afterwards:

export class TceSwTemplateRenderer extends CanvasGameRenderer<Game, TceSwTemplateSettings> {
    protected paint(_context: CanvasRenderingContext2D, layout: StageLayout, frame: GameFrame<Game>): void {
        const ball = frame.game.ball;
        this.disc(ball.x, ball.y, frame.game.radius, this.settings.colors.orange);
    }
}

The start screen comes for free, with settings.rule under the title. A game can add a picture above the title (start_picture_height and paint_start_picture()), its scene behind it (paint_start_backdrop(), which is told where the text will go so the scene can keep clear of it) and a card under the text (start_card); the shell keeps everything centred.

For paint() the base class has the house's drawing pieces:

Sound works like images. A game lists its files in settings.sounds (name to imported file) and the shell loads them when the element starts; properties.audio, an AudioPlayer from step 17, plays them by name. settings.music names the track that loops while the game can be played: the shell starts it when the intro is over, stops everything when the element disconnects, brings it back on a reconnect, and retries a track the browser refused on the next click or key. Mutes and volumes live on the player, so they survive a new game and a reconnect. A hidden tab stops the frame loop and with it the game, so the shell holds the track too, and it goes on from the same spot on return.

Mute buttons come with it. A game names a rectangle in its layout (sound_button, music_button, from MuteButtonsLayout), and the shell builds the buttons, brings their icons, wires clicks and the keys S and M, and keeps them showing what properties.audio says. A game with sounds but no track gets only the sound button. A letter the game needs for itself is freed in settings.sound_mute_key or settings.music_mute_key, with another letter or null.

The toolbox UI comes with the house's button classes, primary, secondary and accent, one button_font_size setting, and HouseSlider, the toolbox slider in the house look with its name and value above or beside the track.

The UI page

Every game already has a page: properties.page, a UiPage the shell makes, feeds with the pointer and draws over the scene after paint(). One rule decides what goes on it: what you can press is a widget, what you only look at may be painted. A score on a playfield stays paint. Step 20 is the game built to test all of this.

this.properties.set_theme(theme_rules_from([
    ['panel', { background_color: hex_color('#0d1130') }],
    ['button', { background_color: hex_color('#10163a'), text_color: hex_color('#6cdad9') }],
    ['button:disabled', { text_color: hex_color('#5f6ba8') }],
    ['button.late', { background_color: hex_color('#d48c17') }],
    ['button.late:hover', { background_color: hex_color('#d48c17') }],
]));

The strongest rule wins: .class:state, then :state, then .class, then type, and among equals the later one. So button:hover beats button.late, which is why that list needs the last line.

docs/ui.md in the toolbox has the whole system.

Three traps the shell holds for you

The reason this is a module and not a snippet: each of these took a bug to learn.

Sound. Browsers refuse to play sound until the visitor has clicked or typed on the page. So the shell's IntroGate opens on a start screen unless navigator.userActivation.hasBeenActive, plays the logo with sound on that very click (muted again if the browser still refuses), and holds the last frame 1.5 s. Every failure (no library, no network, no H.264) ends in done; the game never depends on the intro. The gesture that leaves the start screen is consumed there and never reaches the game.

Fitting. The play page gives a game the whole window, from a wide monitor to an upright phone. CanvasStage scales the layout to the largest size that fits, centred, and picks the layout that shows the game largest when settings.layouts has several (a later one must win by 5 %, so a window near the break-even shape doesn't flip). The trap is the toolbox UI: a UiPage maps its coordinates onto a unit rectangle of the canvas, and left at 0..1 it stretches over the whole canvas while the scene is centred. The stage places every attached page in exactly the scene's rectangle on each resize, and reports a layout change to on_layout() so the game can move its buttons.

Input order. A click must go to the start screen first, then to the toolbox UI in canvas pixels, and only then to the game, and a UiPage counts as hit everywhere, so "the UI took it" means a widget other than the page holds the pointer. bind_stage_input does that routing once; the game only sees on_press(point). It also captures the pointer on a press, so a drag keeps reporting outside the canvas, and a cancelled touch never clicks the button under it.

Underneath sit play_video_splash, fit_layout, pick_layout, draw_start_screen and bind_stage_input in src/stage/, each usable on its own, each with a test next to it. The one with the subtle test is the hold: the video's timeout must stop counting once the video has ended, or a long hold would end as "timeout".

Extracted along the way

The shell grew while the steps were ported onto it. Whenever two steps carried the same code, it moved into the toolbox, with a test, and the step that first needed it now just calls it:

In the shell now First written by hand in
properties.place(widget, rule): widgets follow the layout 01 Zahlen raten (the pad in portrait)
paint_header(), HeaderLayout, settings.rule 01 Zahlen raten
Seed series init-seed + n 01 Zahlen raten
Button classes primary and secondary 01 Zahlen raten
bar(): a progress bar on a track 01 Zahlen raten (the range bar)
properties.keyboard, settings.game_keys 02 Galgenraten (KeyboardState)
A picture on the start screen 02 Galgenraten
properties.pointer for hover 03 Tic-Tac-Toe
wrap(): text in lines 04 Mastermind
SessionBest: the best result of the session 05 Lock Picker
dim() and panel(): the card over the scene 06 Olympische Sprünge
outline() 08 Koordinaten-Picker
on_change(), pointer capture, sliders in layout coordinates 09 Farb-Picker
The scene behind the start screen 11 Flappy Bird
on_shortcut() for Ctrl+Z 14 Pixel Paint
@spielwerk/boot: the logo source, window titles and the package binding every step (each had its own src/boot/ and a 25-line src/index.ts)
sync(): widgets follow the game's state 01 Zahlen raten (pad or "Neues Spiel")
Button class accent 01 Zahlen raten ("Raten")
grid_cell(): keys in rows and columns 01 Zahlen raten (the pad)
live_status(): a status line that follows the game 02 Galgenraten
FrameExtras<Frame>, repaint on pointer move 03 Tic-Tac-Toe
fill_lines(), fill_wrapped() 04 Mastermind
grid_in(): an area split into keys 01 Zahlen raten
disc(), ellipse(), CENTRED 02 Galgenraten (the pips), and nearly every renderer since
ready_status() defaults to the live status 03 Tic-Tac-Toe
stat(): a value under its label 05 Lock Picker
fill_wrapped_in(): a message card 05 Lock Picker
result_panel(): the end of a round 05 Lock Picker ("Geknackt!")
HouseSlider 09 Farb-Picker
on_key_press(): a held key counts once 05 Lock Picker
text(), font(), caption(): one line in one call every renderer; 05 Lock Picker first
A header rule with its own size and wording, inner_outline(), clip_round(), rect_contains() 08 Koordinaten-Picker
A card under the start screen's text 09 Farb-Picker
pill(): the "next round" button on the canvas 10 Hau den Maulwurf
visible: the margins around the layout 11 Flappy Bird
button_font_size 12 Side-Scroller
settings.images and paint_image(): pictures by name, loaded by the shell 15 Memory, the first step with image files, before any step wrote it by hand
is_round_finished(), on_round_finished(), properties.best: a finished round scored once, the next one by click or Space or Enter 10 Hau den Maulwurf and 15 Memory, moved when 16 Flummis would have been the third copy
settings.sounds, settings.music, properties.audio: sounds by name, music only after the intro and never after the element 17 Bricks, the first step with sound, before any step wrote it by hand
message_card(): a message on a card of its own, in one call 05 Lock Picker, 06 Olympische Sprünge, 15 Memory and 16 Flummis, moved when 17 Bricks would have been the fifth copy
properties.sound_button, properties.music_button, MuteButtonsLayout: mute buttons from a rectangle in the layout, with S and M 17 Bricks, moved when 18 Rundenkampf would have been the second copy
FixedStepClock (src/flow/timers/): uneven frames into even steps, what a frame leaves over kept for the next 16 Flummis and 17 Bricks, moved when 19 Schleim-Abwehr would have been the third copy
Group.remove(): a removed widget loses its press and hover 20 Stellwerk, before any step wrote it by hand
ScrollGroup (src/ui/widgets/): a list that scrolls by drag and wheel 20 Stellwerk, before any step wrote it by hand
on_refused(), is_disabled, is_selected: a disabled widget says why 20 Stellwerk, before any step wrote it by hand
properties.set_theme(): swap the game's rules, the house rules stay 20 Stellwerk, before any step wrote it by hand
place(widget, rule, parent): widgets inside a panel follow the layout too 20 Stellwerk, before any step wrote it by hand
TimedNotice: a message that shows for a moment 19 Schleim-Abwehr, moved when 20 Stellwerk would have been the second copy
settings.round_end_hold_seconds: an end card a press meant for the game can't skip 19 Schleim-Abwehr and 20 Stellwerk
settings.themes, theme_name(): a game's looks by name, the shell swaps 20 Stellwerk, which kept the name of the shown theme by hand
theme_rules on a layout: rules for the phone alone 20 Stellwerk, which put the layout's name into the theme's
Button.columns, text_align on a Button: rows of a table 20 Stellwerk, which padded its labels with spaces
ScrollGroup with row_height, row_gap and padding: rows that stack 20 Stellwerk, which set each row's rect itself
stays_on_top: the mute buttons paint over the game's widgets and get hit first 20 Stellwerk, whose panels went around them
theme_font(): every widget reads font_weight and font_style 20 Stellwerk, before any step wrote it by hand
thumb_color, caption_color, slider_house_rules(): HouseSlider follows the theme, one way to draw a slider 09 Farb-Picker, moved when 20 Stellwerk wanted its slider in the theme
transition_seconds, settings.press_transition_seconds: values move from what shows now 20 Stellwerk, before any step wrote it by hand
theme_pulse(), theme_shake(), show_refused(): rules that keep moving, a press that shows it was refused 20 Stellwerk, before any step wrote it by hand
set_theme(rules, fade_seconds), theme_fade_seconds, blended(): a fade that paint follows too 20 Stellwerk, before any step wrote it by hand
MenuInput (src/input/): keys and presses for a Menu, and a buffer of presses through a hold 21 Amtsschimmel, before any step wrote it by hand; 18 Rundenkampf dropped its own keys for it
MenuView, MenuRow, MenuHint, MenuPath (src/ui/widgets/): a menu on the page in a style, with level for a cascade 21 Amtsschimmel, which paints no row
row_hint_share: every entry's hint in its own row, with is_caption on a Button column 18 Rundenkampf, when it moved to MenuView
back, key, levels, back_to(), set_entries(), stops_on_disabled on Menu 21 Amtsschimmel, where 18 Rundenkampf caught a 'back' value by hand
border_style: "raised" and "sunken": a bevel from border_color 21 Amtsschimmel; the name existed, and nothing read it
show_entering(), :entering, theme_slide(): what has just appeared can arrive, through the rules 21 Amtsschimmel, before any step wrote it by hand
Label.is_wrapped, Label.text_padding, Button.text_overflow, wrap_text(), ellipsize_text(): text that fits wrap() in 04 Mastermind, for widgets when 21 Amtsschimmel needed it
ScrollGroup.reserves_gutter: rows that fit take the whole width 21 Amtsschimmel, before any step wrote it by hand
A placed MenuView's input runs in the shell: keys after the intro, update(), end_frame() 21 Amtsschimmel, before any step wrote it by hand
QuadTree.pick_polygon(), Rect.touches_segment(), Polygon.bounding_rect, QuadTree.clear(keep_levels): a shape asks a tree about whole cells, and a refilled tree can shrink 22 Leuchtfeuer, before any step wrote it by hand
clamp, lerp, angle_difference from the toolbox's root 22 Leuchtfeuer, the first step to import them
Rect.contained(): a picture's shape inside a box paint_image() in 15 Memory, shared when 23 Diaabend needed it for Image and Gallery

Three of them fixed toolbox bugs on the way. A Slider used to get canvas pixels in drag() while hit() tested layout coordinates, so it only worked on an unscaled page; UiManager now hands both the same coordinates. A cancelled touch used to click the button it started on. And a press that was dragged off a button still clicked it on release, which 19 Schleim-Abwehr found as soon as a drag moved the world.

The toolbox as a package

The toolbox grows by one module per step. Each module lives in its own folder with a test next to it. Releases come in waves: after each group of steps the toolbox gets a new minor version, a git tag, a release on npm and an sdk upload to the registry. Every step page names the version its module first shipped in.

Next

Next: Spielwerk 01: Zahlen raten · All steps

Comments

captcha