5 the closure parked on a shared object
GOAL
Chase down, explain, and repair the bug this whole dojo is built around: an arrow function dropped onto a SHARED prototype slot during construction pins `this` to whichever instance booted LAST — so the knight's match starts handing back the rogue's hero. Then meet the mirror image: a spot where the arrow IS the fix.
CONCEPT
arrow = `this` captured LEXICALLY, once, when the arrow is written. function = `this` looked up at CALL time from the call site. A prototype method is a DISPATCHER shared by many instances — it has to find its instance on every call, so it must be a `function`. A callback the engine later fires as a plain call gets no receiver at all — it has to CAPTURE, so it wants an arrow.
HINT
TODO 1: hoist the accessor out of the constructor and hand the prototype ONE plain `function` under `Game.prototype.getHero`. TODO 2: the callback pushed onto the queue should become `() => {...}`.
MIRRORS
The classic split-screen / minimap bug in game engines and UI libraries: a closure that captures one instance gets stashed on a shared prototype (or a module singleton) during init — from then on every running match reports the last one that booted. Engine prototype methods are plain `function`s for exactly this reason: `this` re-resolves per game, per call.
Run
node 05-arrow-on-shared-proto-wrong-game.cjsSource
'use strict';
// ---------------------------------------------------------------------
// PROTO-DOJO · Module 2 / Exercise 5 — the closure parked on a shared
// object
// serves the WRONG game
//
// GOAL: Chase down, explain, and repair the bug this whole dojo is
// built around: an arrow function dropped onto a SHARED
// prototype slot during construction pins `this` to whichever
// instance booted LAST — so the knight's match starts handing
// back the rogue's hero. Then meet the mirror image: a spot
// where the arrow IS the fix.
// CONCEPT: arrow = `this` captured LEXICALLY, once, when the arrow is
// written. function = `this` looked up at CALL time from the
// call site. A prototype method is a DISPATCHER shared by many
// instances — it has to find its instance on every call, so it
// must be a `function`. A callback the engine later fires as a
// plain call gets no receiver at all — it has to CAPTURE, so
// it wants an arrow.
// HINT: TODO 1: hoist the accessor out of the constructor and hand
// the prototype ONE plain `function` under
// `Game.prototype.getHero`. TODO 2: the callback pushed onto
// the queue should become `() => {...}`.
// MIRRORS: The classic split-screen / minimap bug in game engines and
// UI libraries: a closure that captures one instance gets
// stashed on a shared prototype (or a module singleton) during
// init — from then on every running match reports the last one
// that booted. Engine prototype methods are plain `function`s
// for exactly this reason: `this` re-resolves per game, per
// call.
//
// Run: node 05-arrow-on-shared-proto-wrong-game.cjs
// ---------------------------------------------------------------------
const assert = require('node:assert');
function Game(id) {
this.id = id;
this.players = [{ id: id + '-hero', hits: [] }];
this.renderCount = 0;
// -- TODO 1 -- a well-meaning "speed-up" wires the accessor up during boot.
// Two things go wrong on this one line:
// a) every `new Game(...)` OVERWRITES the same shared prototype slot, and
// b) the arrow pins `this` to the game under construction — so once
// the
// screen is up, the slot answers for whichever game booted LAST.
// Lift the method out of the constructor and make it an ordinary `function`
// on the prototype: a dispatcher that finds its game through `this` at CALL
// time. All instances must share ONE function.
Game.prototype.getHero = () => this.players[0];
}
Game.prototype.scheduleRender = function (queue) {
// -- TODO 2 -- the other side of the coin. The render queue fires this
// callback later as a PLAIN call (this === undefined) — a `function`
// here
// looks `this` up at call time and comes back empty-handed. THIS is where
// the arrow earns its keep: it would capture scheduleRender's `this`, which
// IS the game. Convert it.
queue.push(function () {
this.renderCount += 1;
});
};
// -- the game screen boots two matches --------------------------------
const knightGame = new Game('knight');
const rogueGame = new Game('rogue');
assert.strictEqual(
knightGame.getHero().id,
'knight-hero',
'TODO 1: the knight\'s match answered with "' + knightGame.getHero().id + '" — the arrow parked on ' +
'the shared prototype was pinned to the LAST game constructed. `this` must resolve when the method is CALLED.'
);
assert.strictEqual(rogueGame.getHero().id, 'rogue-hero');
assert.strictEqual(
knightGame.getHero,
rogueGame.getHero,
'TODO 1: keep ONE shared function on the prototype — per-instance copies dodge the lesson (and cost memory per game)'
);
// booting a third match must never re-aim the first two again:
const mageGame = new Game('mage');
assert.strictEqual(knightGame.getHero().id, 'knight-hero',
'TODO 1: constructing the mage\'s match re-aimed EVERY game on the screen');
assert.strictEqual(mageGame.getHero().id, 'mage-hero');
// -- the render queue flushes callbacks as PLAIN calls ----------------
const renderQueue = [];
knightGame.scheduleRender(renderQueue);
rogueGame.scheduleRender(renderQueue);
let boom;
try {
for (const callback of renderQueue.splice(0)) callback(); // no receiver!
} catch (err) {
boom = err;
}
assert.strictEqual(
boom,
undefined,
'TODO 2: flushing the queue threw "' + (boom && boom.message) + '" — the `function` callback re-resolved ' +
'this at call time and the queue offered nothing. An arrow inside scheduleRender captures the game lexically.'
);
assert.strictEqual(knightGame.renderCount, 1, 'each queued render must hit ITS OWN game');
assert.strictEqual(rogueGame.renderCount, 1, 'each queued render must hit ITS OWN game');
console.log('PASS — 05-arrow-on-shared-proto-wrong-game: dispatchers resolve, callbacks capture');