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
- TCE v3. Every game is a Tobidot Custom Element: a web component that runs on any page with one script tag and one HTML tag, opens as a window on this site's desktop and has its own play page. The spec is in Documentation › TCE Framework.
- ts-game-toolbox. My collection of game helpers: vectors and rectangles, signals, a canvas UI with buttons and sliders, a QuadTree, asset loading. Every step links the same source folder instead of a copy, so a module added for one step is there for the next.
- dll-tce-assets. A small runtime library with a shared file catalog. An element asks for a name like
go-logo/intro-short-16x9and gets a URL, the same one for every element on the page, so the logo is downloaded once, not once per game.
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:
Settings: constants and attributes.References: the DOM, built once. Rarely subclassed: the base builds all a canvas game needs.Properties: the state, including the toolbox UI objects.Renderer: painting only.Logic: the rules.
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:
- Lifecycle:
read_attributes(read),on_start(signal),on_stop(),on_new_game(game),on_layout(layout). - Every frame:
update(game, delta_seconds)(readproperties.keyboardhere),frame(game)for what the renderer needs beyond the game (typedFrameExtras<Frame>). - Before every repaint:
sync(game)sets what follows from the game's state outside the picture: which buttons show, their labels and classes. No more toggling widgets by hand after each move. - End of a round:
is_round_finished(game)says the game has ended. The shell then callson_round_finished(game)exactly once, before the frame that shows the end, where a game offers its result toproperties.best; settles that best on every new game; and turns a press or Space or Enter into the next round (start_next_round()), soon_press()and the key hooks never see a finished game. - Status line:
ready_status(game)after the intro and after each restart,intro_status(state)before.live_status(game)keeps the line following the state after every repaint; events ("Treffer, 12 Punkte") still go throughset_status(). - Pointer:
on_press/on_move/on_release(point, event),on_leave(event). They get layout coordinates and run only after the intro, and only when no toolbox button took the press.properties.pointeralways holds the last position, and a move repaints by itself, so a hover effect needs no hook at all. - Keys:
on_key_down/on_key_up(event),on_key_press(event)once per press however long the key is held, andon_shortcut(event)for Ctrl, Alt or Meta.
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:
- Text:
text(content, x, y, { size, is_bold, color, align })sets a line in one call, in the game's font ({ ...CENTRED }centres it both ways);caption()is the small muted label over a value;wrap(),fill_lines(),fill_wrapped()andfill_wrapped_in(text, rect)break longer text. - Header:
paint_header()draws the title and the rule; a layout may give the rule its own size and wording. - Shapes:
card(),outline()andinner_outline(),disc()andellipse(),pill()for "Leertaste: neue Runde",bar()for progress,clip_round()for a picture inside a card. - Cards:
stat(rect, label, value)for a HUD value under its label;message_card(rect, text, { size, is_bold, color })for what happened or what to do next, wrapped to the card;result_panel({ title, value, best, lines, action })for the end of a round, over the dimmed scene, with the session best in orange when it is new. - Over the scene:
dim()withpanel()for a card of its own;visibleis everything the canvas shows, margins included, for a sky that runs to the screen's edge. - Layout:
rect_contains(rect, point), andgrid_in(area, columns, rows, gap)withgrid_cell(grid, index)for keys in rows. - Images: a game lists its files in
settings.images(name to imported file) and the shell loads them when the element starts.paint_image(name, rect, { is_contained, is_smooth })paints one, sharp unless told otherwise, and paints nothing until it has loaded; the screen repaints when it arrives.
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.
- Layout coordinates. The page's coordinates are the layout's, whatever the canvas really measures. Inside a
Panelor any otherGroup, a rect is relative to its parent. place().this.place(widget, (layout) => rect, parent)adds the widget and keeps it there in every layout. The parent is optional and defaults to the page.- Handlers. In
on_start():on_click(widget, handler),on_change(widget, handler)for a slider or checkbox, andon_refused(widget, handler)for a press on a disabled widget, which is where a game says why. Touch has no hover to say it with. - Classes and states.
widget.classis yours.is_disabledandis_selectedare yours too, usually set insync();activeandhovercome from the pointer. A state has this priority:refused,disabled,active,selected,hover.refusedlasts a moment, aftershow_refused(). A disabled widget still gets hit and hover, so a press can't fall through to the game, and it never clicks or drags. - Theme rules. A theme is a list of rules,
type.class:statewith properties. The house rules cover all widgets, so one with no rules looks finished. A game adds what differs, as the third argument to thePropertiesconstructor or withset_theme():
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.
- Themes by name. A game with several looks lists them in
settings.themesand overridestheme_name(game), and the shell swaps when the name changes.settings.theme_fade_secondsfades between them while the game runs; a new game and a new layout snap. What the game paints follows withproperties.blended(table), a table with one entry per theme.theme_ruleson a layout lie on top, for what a phone needs differently. - Motion. The rules say this too, so a game writes no timers.
transition_secondstakes a color, a number or a transform from what shows now to the new value, and the house rules havesettings.press_transition_secondson buttons, checkboxes and sliders.theme_pulse(),theme_shake()andThemeValueAnimatorkeep moving for as long as their rule applies. The shell keeps painting while something moves. In a test,set_theme_clock()steps the time. - Rows. A
ScrollGroupwithrow_heightstacks its children, withrow_gapandpadding.text_alignmoves aButton's text, andcolumnsshows several texts in one row.
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