1 dispatcher on the prototype
GOAL
Bolt a `requestUpdate` batching API onto a third-party Game class the way engine plugins do it: ONE stateless dispatcher on the prototype plus PER-INSTANCE state born in an init event. Then PROVE that two running matches never touch each other's queue.
CONCEPT
A method on the prototype is one function shared by every instance, so it cannot own any state of its own. State parked in the module closure is worse still: every match in the process shares it. The pattern: - prototype method = stateless dispatcher, reads/writes `this._xxx` - init event hook = hands each instance its own `this._xxx` The starter plugin below ships with the classic bug: closure state.
HINT
Remove the module-level array. Inside the 'init' hook, create the state on `this` (the game being constructed). In the dispatchers, touch nothing but `this._pendingPatches` — never anything from the surrounding closure.
MIRRORS
Plugins for UI toolkits, game engines and event emitters all look alike: `addEvent(Game, 'init', function () { this.xyz = ... })` paired with `Game.prototype.someMethod = function () { ...this.xyz... }`. A lobby server hosts dozens of matches at once — closure state means match B flushes match A's pending settings patches.
Run
node 01-dispatcher-on-the-prototype.cjsSource
// ---------------------------------------------------------------------
// 04-patching-foreign-code / 01-dispatcher-on-the-prototype.cjs
//
// GOAL: Bolt a `requestUpdate` batching API onto a third-party Game
// class the way engine plugins do it: ONE stateless dispatcher on
// the prototype plus PER-INSTANCE state born in an init event.
// Then PROVE that two running matches never touch each other's
// queue.
//
// CONCEPT: A method on the prototype is one function shared by every
// instance, so it cannot own any state of its own. State
// parked in the module closure is worse still: every match in
// the process shares it. The pattern:
// - prototype method = stateless dispatcher, reads/writes
// `this._xxx`
// - init event hook = hands each instance its own
// `this._xxx` The starter plugin below ships with the
// classic bug: closure state.
//
// HINT: Remove the module-level array. Inside the 'init' hook, create
// the state on `this` (the game being constructed). In the
// dispatchers, touch nothing but `this._pendingPatches` — never
// anything from the surrounding closure.
//
// MIRRORS: Plugins for UI toolkits, game engines and event emitters all
// look alike: `addEvent(Game, 'init', function () { this.xyz =
// ... })` paired with `Game.prototype.someMethod = function ()
// { ...this.xyz... }`. A lobby server hosts dozens of matches
// at once — closure state means match B flushes match A's
// pending settings patches.
//
// Run: node 01-dispatcher-on-the-prototype.cjs
// ---------------------------------------------------------------------
'use strict';
const assert = require('node:assert');
// -- Third-party `engine` library (pretend node_modules — do not edit)
// ---
const hooks = { init: [] };
function addEvent(Class, type, handler) {
hooks[type].push(handler);
}
class Game {
constructor(settings) {
this.settings = settings;
hooks.init.forEach((h) => h.call(this)); // fires like the engine's 'init'
}
render() { this.rendered = (this.rendered || 0) + 1; }
}
// -- YOUR PLUGIN MODULE (fix the TODOs) -------------------------------
// BUG: this array lives in the MODULE CLOSURE — one array for every
// match
// that will ever run in this process.
let pendingPatches = [];
addEvent(Game, 'init', function () {
// TODO 1: give THIS game its own state. The dispatchers below must
// find an own `_pendingPatches` array on every constructed game.
});
Game.prototype.requestUpdate = function (patch) {
// TODO 2: stateless dispatcher — queue onto THIS game's state, not
// the
// closure array.
pendingPatches.push(patch);
};
Game.prototype.flushUpdates = function () {
// TODO 3: merge THIS game's queued patches into this.settings, clear only
// THIS game's queue, render, and return the merged patch object.
const merged = Object.assign({}, ...pendingPatches);
pendingPatches = [];
Object.assign(this.settings, merged);
this.render();
return merged;
};
// -- Checks (do not edit) ---------------------------------------------
const knight = new Game({ hero: 'knight', volume: 30 });
const rogue = new Game({ hero: 'rogue', volume: 30 });
// The dispatcher itself must be SHARED — that part of the pattern is
// free:
assert.strictEqual(knight.requestUpdate, rogue.requestUpdate,
'the dispatcher stays ONE function on Game.prototype — the plugin adds ' +
'no per-instance functions');
// But the STATE must be per-instance, created at init:
assert.ok(Array.isArray(knight._pendingPatches),
'TODO 1: the init hook must create this._pendingPatches on every game ' +
'(`this` inside the hook IS the game being constructed)');
assert.notStrictEqual(knight._pendingPatches, rogue._pendingPatches,
'TODO 1: each game needs its OWN array — a shared one is the module-' +
'closure bug wearing a different costume');
knight.requestUpdate({ volume: 80 });
knight.requestUpdate({ difficulty: 'hard' });
rogue.requestUpdate({ volume: 10 });
assert.strictEqual(knight._pendingPatches.length, 2,
'TODO 2: the knight match queued exactly 2 patches — if you see 3, the ' +
'rogue patch leaked in through shared state');
assert.strictEqual(rogue._pendingPatches.length, 1,
'TODO 2: the rogue match queued exactly 1 patch — isolation is the whole point');
const knightMerged = knight.flushUpdates();
assert.deepStrictEqual(knightMerged, { volume: 80, difficulty: 'hard' },
'TODO 3: flushing the knight match merges ONLY its own patches');
assert.strictEqual(knight.settings.volume, 80, 'TODO 3: merged into this.settings');
assert.strictEqual(knight.settings.difficulty, 'hard', 'TODO 3: merged into this.settings');
assert.strictEqual(knight.rendered, 1, 'TODO 3: flush renders the game');
// THE isolation proof: flushing match A must not drain match B.
assert.strictEqual(rogue._pendingPatches.length, 1,
'ISOLATION: knight flushed — rogue must still hold its own pending patch');
const rogueMerged = rogue.flushUpdates();
assert.deepStrictEqual(rogueMerged, { volume: 10 },
'ISOLATION: rogue flushes only its own patch, untouched by knight activity');
assert.strictEqual(rogue.settings.hero, 'rogue', 'sanity: settings intact');
assert.strictEqual(knight.settings.volume, 80,
'ISOLATION: rogue\'s volume:10 must NOT bleed into the knight match');
console.log('OK — 01-dispatcher-on-the-prototype: one dispatcher, private queues.');