A lock with five pins, a marker sweeping up and down the active pin, and one press at the right moment. Nothing is drawn, guessed or placed here, only timed, and timing in a frame loop needs two small classes the toolbox did not have yet: Timer and Cooldown.
Play it · Source: collection-spielwerk/05-lock-picker · Toolbox 5.4.0 or later
The game
A marker sweeps up and down the active pin, and somewhere on the pin lies a bright zone. Press while the marker is inside it and the pin is set; press outside and the pin before it drops out again, both zones are re-rolled and the game ignores input for 0.4 seconds. With every pin the zone shrinks and the marker gets faster. Set all five and the lock reads "Geknackt!" (cracked) with your time; the time is the score, and the best time of the session stays. "Neues Schloss" (new lock) starts the next one.
- Mouse/touch: a click or tap anywhere on the lock is a pick; the button starts a new lock.
- Keyboard: Space picks, and on a cracked lock starts the next one.
New in the toolbox: Timer and Cooldown
The frame loop calls the game every few milliseconds, but how many is up to the browser. A background tab, a slow machine or a hiccup while loading, and the frames arrive 16, 33 or 400 milliseconds apart. If I count frames ("after 30 updates the marker is at the top"), every machine plays a different game, and the fifth pin is easier at 30 frames per second than at 144. So the loop passes delta_seconds, the real time since the last frame, and everything that moves works with that.
A Timer is that idea, tidied up once: it adds up deltas and tells me when a duration has passed. A Cooldown is the other direction: not "is it time yet" but "is it allowed again".
const sweep = new Timer(1.0, { repeat: true, autostart: true });
sweep.update(delta_seconds); // every frame; returns how often it completed
sweep.progress // 0..1 through the current run
sweep.ping_pong // 0..1..0 over two runs
sweep.completions // runs completed since the last reset()
The core is update(). It returns how many times the timer completed during this call: 0, 1, or more when a repeating timer gets a big delta. The rest carries over instead of being dropped:
public update(delta_seconds: number): number {
if (!this._is_running || !(delta_seconds > 0)) {
return 0;
}
this._elapsed += delta_seconds;
if (this._elapsed < this.duration) {
return 0;
}
if (!this.repeat) { // one-shot: stop at the end
this._elapsed = this.duration;
this._completions += 1;
this._is_done = true;
this._is_running = false;
return 1;
}
const completions = Math.floor(this._elapsed / this.duration);
this._elapsed -= completions * this.duration; // keep the remainder
this._completions += completions;
return completions;
}
A one-shot timer stays at progress 1, is_done, until start() runs it again from zero; stop() pauses, reset() goes back to zero. That is the whole class.
ping_pong is the entire mechanic of Lock Picker. A marker running up and down is a repeating timer whose value rises on even runs and falls on odd ones:
public get ping_pong(): number {
const progress = this.progress;
return this._completions % 2 === 0 ? progress : 1 - progress;
}
The timer's duration is the pin's speed, the zone is an interval on the same 0..1, and a pick asks whether ping_pong lies inside it. Because the timer knows nothing about the frame rate, the fifth pin is equally hard on every machine.
Cooldown has three moves. try_use() answers true and starts the wait when it is ready, otherwise false and does nothing. update() runs the wait down. progress goes from 0 right after a use to 1 when ready again, which is exactly the fill of the recharge bar under the lock.
The game uses both in a few lines:
// src/game/Game.ts, trimmed
public get marker(): number {
return this.sweep.ping_pong; // 0 bottom .. 1 top .. 0 bottom
}
public update(delta_seconds: number): void {
if (!(delta_seconds > 0) || this.is_cracked) {
return;
}
this.elapsed_seconds += delta_seconds;
this.cooldown.update(delta_seconds);
this.sweep.update(delta_seconds);
}
public pick(): PickResult {
if (this.is_cracked || !this.cooldown.is_ready) {
return 'blocked';
}
if (this.is_marker_in_zone) {
// set the pin, faster sweep
return 'hit';
}
this.cooldown.try_use(); // 0.4 s lockout
/* drop the pin before, re-roll the zones, restart the sweep */
return 'miss';
}
The tests (src/flow/timers/Timer.test.ts) pin down the carry-over and the cooldown:
const timer = new Timer(0.5, { repeat: true, autostart: true });
expect(timer.update(0.3)).toBe(0);
expect(timer.update(0.3)).toBe(1); // 0.6 s: once through, 0.1 left
expect(timer.update(1.65)).toBe(3); // a hiccup: three times through
expect(timer.elapsed).toBeCloseTo(0.25);
const cooldown = new Cooldown(0.4);
expect(cooldown.try_use()).toBe(true);
expect(cooldown.try_use()).toBe(false);
cooldown.update(0.1);
expect(cooldown.progress).toBeCloseTo(0.25);
The trap without them: a timer that resets to zero on completion loses the remainder, and after a hiccup the marker jumps. Neither class reads a clock; time only comes in through update(), so a test can push them to any moment it needs. Path of the module: src/flow/timers/Timer.ts.
From earlier steps
SeededRandomfrom step 01: rolls where every zone lies, so the same seed is the same lock.
The scaffold, start screen, intro and scaling are explained once in Spielwerk 00: The workbench. The title and the "Neues Schloss" button are a toolbox UiPage with a Label and a Button. The shell passes on only presses no button took, so every on_press() is a pick. The best time is a SessionBest, a small class in the toolbox that keeps the best result of the session: offer() a result and it says whether that beat the record, and because it lives in the properties it survives "Neues Schloss" and a moved window, but not a reload. What this step first wrote by hand for the session best, one-press keys, stat cards, the message card and the "Geknackt!" panel is part of the canvas game shell now; the workbench lists it under "Extracted along the way".
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-lock-picker"></script>
<tce-lock-picker init-seed="4711"></tce-lock-picker>
| Attribute | Values | Default |
|---|---|---|
init-seed |
whole number; the first lock, every new lock is seed + 1 | random |
init-intro |
true / false; play the GO logo first |
true |
Next
Previous: Spielwerk 04: Mastermind · Next: Spielwerk 06: Olympische Sprünge · All steps
Comments