diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0c17e36
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+# Locally built/downloaded bundles for reference-game-cwg-local.html. Deliberately NOT committed —
+# this repo has twice reverted vendoring prebuilt multiplayer bundles into git. Rebuild them with the
+# commands in that file's header comment.
+vendor/
diff --git a/reference-game-hawkins-local.html b/reference-game-hawkins-local.html
new file mode 100644
index 0000000..a8dcd89
--- /dev/null
+++ b/reference-game-hawkins-local.html
@@ -0,0 +1,474 @@
+
+
+
+ Reference Game — Hawkins et al. 2020 replication (sequential) — local build
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/reference-game-hawkins.html b/reference-game-hawkins.html
index d3c055d..a6307e2 100644
--- a/reference-game-hawkins.html
+++ b/reference-game-hawkins.html
@@ -21,6 +21,83 @@
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/jspsych/jsPsych@151ab520542a8e48bcc4d5b21c74cdffae8b48c6/packages/jspsych/css/jspsych.css"
/>
+
@@ -45,8 +122,26 @@
// matcher can't blind-guess to rush through — verified in hawkrobe/tangrams game.client.js,
// where the click handler is gated behind `if (globalGame.messageSent)`.
//
- // Every parameter above was verified against the paper + hawkrobe/tangrams code. The one departure
- // is not a design choice: `round_timeout` (60s) has no counterpart in the original, which ran
+ // Re-audited line by line against the original source (OSF osf.io/vzvmf → github component =
+ // hawkrobe/tangrams, `experiments/tangrams_sequential`, i.e. the "cued" version — the README
+ // there defines cued as "a sequential version that cues each target in a sequence of trials").
+ // Confirmed identical: 2 players; fixed director/matcher; 6×2 grid of SQUARE cells; 72 rounds as
+ // 6 shuffled blocks of 12 (game.core.js `getRandomizedConditions`); layouts re-randomised every
+ // round, independently per player; target revealed to the director only; unrestricted two-way
+ // chat; per-round chat log; the matcher-click gate (`globalGame.messageSent`); 3000ms of
+ // feedback before the next round (game.server.js `setTimeout(..., 3000)`); a typing indicator
+ // (sharedUtils/clientBase.js `playerTyping`); and a persistent round counter + running score.
+ //
+ // Position disjointness and per-role feedback were gaps until the plugin gained
+ // `scramble_mode: "disjoint"` and role-keyed `feedback_content`; both are now used below, and
+ // the always-green feedback highlight is restored in CSS. ONE known difference remains:
+ // Gate semantics. The original sets `messageSent` in its `chatMessage` handler, which fires
+ // for ANY message including the matcher's own — so a matcher could unlock their own click by
+ // typing anything. The plugin counts only the PARTNER's message. Ours is the stricter (and,
+ // judging by the source comment, the intended) reading.
+ //
+ // The remaining departure is not a design choice: `round_timeout` (60s) has no counterpart in the
+ // original, which ran
// untimed. It exists so a disconnected or absent partner cannot hang the trial forever. Rounds it
// ends are recorded as `ended_by: "timeout"` with a null assignment — filter on that when
// analysing, and raise it if piloting shows real trials running long.
@@ -141,15 +236,115 @@
conditional_function: () => myRole === "spectator",
};
+ // =============================================================================================
+ // "Partner is typing…" indicator.
+ //
+ // The reference-game plugin has no typing indicator, so this is bolted on from the experiment
+ // using the multiplayer API directly — no plugin fork. It reads/writes ONE extra key in this
+ // participant's session slot, `typing_at` (a timestamp, or null), and renders the partner's.
+ //
+ // Why it is safe to write into the same slot the plugin writes:
+ // `push` REPLACES the whole slot, so every writer must read-modify-write. JS is single
+ // threaded and each read+push below happens in ONE synchronous block, so the plugin's chat and
+ // submit pushes can never interleave between this read and this push — no lost message. The
+ // only writer of my slot is my own tab.
+ //
+ // Rationale for the two constants: the TTL must exceed the throttle, or the hint blinks off
+ // between a continuously-typing partner's pushes.
+ //
+ // The original has this feature (sharedUtils/clientBase.js `playerTyping`), and it matters with
+ // the click gate on: the matcher sits blocked until the director's first message, and this is
+ // what distinguishes "they're composing a description" from "they've gone away".
+ // =============================================================================================
+ const TYPING_TTL = 2500; // hide the hint this long after the partner's last keystroke
+ const TYPING_THROTTLE = 800; // push at most one typing ping per this many ms
+
+ // Torn down at the end of every round by `stopTypingIndicator`; the plugin rebuilds the whole
+ // display each trial, so the hint element and its listeners must be re-created per round too.
+ let typingTeardown = null;
+
+ function startTypingIndicator() {
+ const api = jsPsych.multiplayer;
+ const me = api.participantId;
+ const chat = document.querySelector(".jspsych-multiplayer-reference-game-chat");
+ const form = document.querySelector(".jspsych-multiplayer-reference-game-chat-form");
+ const input = document.querySelector(".jspsych-multiplayer-reference-game-chat-input");
+ if (!chat || !form || !input) return; // chat disabled — nothing to attach to
+
+ const hint = document.createElement("div");
+ hint.className = "typing-hint";
+ hint.setAttribute("role", "status");
+ hint.setAttribute("aria-live", "polite");
+ hint.textContent = `${myRole === "director" ? "Matcher" : "Director"} is typing…`;
+ hint.hidden = true;
+ chat.insertBefore(hint, form);
+
+ // Read-modify-write, synchronously — see the header note.
+ const mark = (ts) => {
+ const mine = api.get(me) ?? {};
+ api.push({ ...mine, typing_at: ts }).catch(() => {});
+ };
+
+ let lastPush = 0;
+ const onInput = () => {
+ if (input.value.trim() === "") {
+ lastPush = 0;
+ mark(null); // cleared the box — stop signalling immediately
+ return;
+ }
+ const now = Date.now();
+ if (now - lastPush < TYPING_THROTTLE) return;
+ lastPush = now;
+ mark(now);
+ };
+ // Runs AFTER the plugin's own submit handler (added first, in trial()), so by the time this
+ // reads the slot the sent message is already in it.
+ const onSend = () => {
+ lastPush = 0;
+ mark(null);
+ };
+
+ const render = () => {
+ const at = partnerId ? api.getAll()?.[partnerId]?.typing_at : null;
+ hint.hidden = !(typeof at === "number" && Date.now() - at < TYPING_TTL);
+ };
+
+ input.addEventListener("input", onInput);
+ form.addEventListener("submit", onSend);
+ const unsubscribe = api.subscribe(render);
+ // subscribe only fires on CHANGES; a partner who stops typing pushes nothing more, so the hint
+ // needs a clock of its own to expire.
+ const ticker = setInterval(render, 500);
+ render();
+
+ typingTeardown = () => {
+ input.removeEventListener("input", onInput);
+ form.removeEventListener("submit", onSend);
+ unsubscribe();
+ clearInterval(ticker);
+ mark(null); // never leave a stale "typing" flag for the next round
+ };
+ }
+
+ function stopTypingIndicator() {
+ typingTeardown?.();
+ typingTeardown = null;
+ }
+
const gameRound = {
type: jsPsychMultiplayerReferenceGame,
stimuli: SHAPES,
columns: 6, // 6×2 grid, as in the original
+ cell_size: 130, // square cells, as in the original's 300px ones (see the