Spielwerk 25: Wochenmarkt

2026-11-01
Spielwerk 25: Wochenmarkt - Teaser

A market stall from eight to one. The wholesale prices move all morning, customers walk up and want apples, bread, cheese or flowers, and you buy crates when they're cheap. One sale changes the cash, the stock, a crate on the counter, four buttons, a ticker line and a coin. This step is about the toolbox's signals, which were there before the series and had no tests: a value that tells whoever watches it, and one socket for everything that happens.

Play it · Source: collection-spielwerk/25-wochenmarkt · Toolbox 5.7.0 or later

The game

You start with 20 €. A crate of five costs five times today's wholesale price, and a customer pays the stall price written next to each good. Buy cheap and you earn more: the wholesale price moves between about a third of the stall price and all of it. When a price falls far, the ticker calls an offer and the button lights up. A customer who finds an empty crate walks away. At one o'clock the market closes, and what's in the cash is your score. If you do nothing, the day passes and you keep your 20 €.

New in the toolbox: signals

The problem. Since step 17, games have told the element what happened with a list: the game pushes 'paddle' or 'ball_lost', and the logic takes the list every frame and plays the sounds. Six steps wrote that list by hand. And every frame sync() copies every number into every label, whether it changed or not. That's fine for a clock. But here one sale touches eight widgets, and a crate button depends on three numbers at once: its price, the cash and the room left on the stall.

The idea. State that something shows is an ObservableSocket. Whoever shows it attaches once and hears every change:

export class Game {
    public readonly cash = new ObservableSocket(START_CASH);
    public readonly events = new EventSocket<MarketEvent>();
    // ... and each good has a price and a stock socket
}

// in Logic, once per new game
game.cash.watch((change) => {
    properties.cash_label.value = euro(change.new);
});

watch() calls once at once, so the label is right from the start. Every listener gets { old, new }, and that's all the price arrow needs: it compares the two.

The crate button is where it pays. It listens to all three of the sockets it depends on, and one function keeps it right:

const refresh = (): void => {
    row.buy.label = `Kiste ${euro(good.crate_cost)}`;
    row.buy.is_disabled = game.why_not(good) !== null;
};
good.price.watch(refresh);
good.stock.attach(refresh);
game.cash.attach(refresh);

Buy a crate of cheese and the cash drops, so all four buttons check again at once. Some go grey, and a press on a grey one says why through on_refused().

Things that happen go through an EventSocket. An event is a small class, and a listener attaches to the class it wants:

export class SaleEvent extends EventBase {
    public static readonly EVENT_NAME = 'wochenmarkt.sale';
    public constructor(public readonly good: Good, public readonly customer: Customer) {
        super(SaleEvent.EVENT_NAME);
    }
}

game.events.attach(SaleEvent, (event) => {
    audio.play_sound('hit-bip');
    properties.coins.push({ x: event.customer.x, age: 0 });
    this.say({ text: `${event.good.type.name}: +${euro(event.good.type.shelf)}`, kind: 'plus' });
});

The game still knows no widget and no sound, and it stays testable without a canvas: a test attaches to the socket and counts. Every widget already has an EventSocket of its own, which is what on_click() attaches to.

sync() keeps only the clock, which changes every frame anyway, and hides the panels for the end card.

What using them found. ObservableSocket.set() told its listeners before it stored the value, so a listener that read get() got the old cash. It stores first now, and setting the same value tells nobody. detach_all() started handles from 0 again, so an old handle could detach a listener attached later. watch() is new. The docs showed an attach() on ChainProperty that doesn't exist. All of it is fixed, with the module's first tests: src/signals/signals.test.ts.

One thing to know. A listener keeps alive whatever it writes to. After "Nächster Markttag" the old day still had the old row's refresh attached. When the browser check changed the old day's cash, that refresh greyed out the new day's buttons. So the game lets everyone go when it's replaced:

public close(): void {
    this.cash.detach_all();
    this.events.detach_all();
    for (const good of this.goods) {
        good.price.detach_all();
        good.stock.detach_all();
    }
}

The logic calls it on the old game in on_new_game(), before it attaches to the new one.

A test worth copying. The bug, as a test:

it('has the new value when it tells, with the old one in the signal', () => {
    const cash = new ObservableSocket(20);
    const heard: string[] = [];
    cash.attach((change) => {
        heard.push(`${change.old}>${change.new}, get ${cash.get()}`);
    });

    cash.set(25);

    expect(heard).toEqual(['20>25, get 25']);
});

From earlier steps

The scaffold, start screen, intro, scaling and on_new_game() are explained once in Spielwerk 00: The workbench.

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-wochenmarkt"></script>
<tce-wochenmarkt init-seed="4711"></tce-wochenmarkt>
Attribute Values Default
init-seed whole number; the first run, every next run is seed + 1 random
init-intro true / false: play the GO logo intro at the first connection true

Next

Previous: Spielwerk 24: Stellschrauben · All steps · Where the series is heading: Civilization

Comments

captcha