From a85785fb66f774d06ca2e24f57679bfb0506c3a6 Mon Sep 17 00:00:00 2001 From: Mandyx22 <1915537307@qq.com> Date: Fri, 31 Jul 2026 14:52:36 -0400 Subject: [PATCH] Align the Hawkins build with the original cued (sequential) experiment Audited reference-game-hawkins.html against the experiment code and manuscript behind the paper: OSF osf.io/vzvmf -> github component = hawkrobe/tangrams. The paper's two variants are "free-matching" (the Clark & Wilkes-Gibbs style board replication, experiments/tangrams_unconstrained) and "cued" -- "a more straightforwardly sequential cued variant ... where directors were privately cued to refer to targets one-by-one and feedback was given on each trial" (experiments/tangrams_sequential). This file is the cued one. Changed to match: - Stimulus geometry. game.core.js lays out SQUARE 300x300px cells and stimuli/objectSet.js draws every tangram at exactly 0.7x its natural PNG size, so the shapes are NOT normalised -- one uniform scale preserves their relative sizes. Cells are square and each image gets width = originalDrawWidth/300 as a percent of the cell content box, exact at any cell_size. - scramble_mode: "disjoint" (new plugin option). The original re-rolls both layouts until no tangram shares a cell between players; plain "independent" left ~1/e of them coinciding, so positional reference sometimes worked by luck. - Role-keyed feedback_content (new plugin option): the director sees only the object the matcher clicked, the matcher only the true target, per the paper -- "the director saw which tangram their partner clicked, and the matcher saw the intended tangram". CSS repaints the plugin's red "wrong" highlight green, since the original highlights green either way and carries the outcome only through the score counter. - Persistent round counter and running score above the board, matching the original's #roundnumber/#score, with show_running_score off. - Instruction wording from client_onjoingame. Only edit: the original tells both roles they may "talk to the director", a copy-paste slip, so each role now names the other. - Typing indicator, built on the multiplayer API rather than forking the plugin. The original has one ("Participants saw a binary indicator that their partner was currently typing"; sharedUtils/clientBase.js playerTyping). Already correct and left alone: 2 players, fixed roles, 6x2 grid, 72 trials as 6 shuffled blocks of 12, per-trial re-scrambling, cued target, unrestricted two-way chat, per-round chat log, the messageSent click gate, the 3000ms feedback window, and unnumbered cells (the paper removed the numbers for the cued version; the plugin only draws slot badges when there is more than one target). Two documented differences remain, in the file header: gate semantics (the original's gate opens on any message, including the matcher's own) and the absence of the original's pre-game comprehension quiz. Requires the plugin's reference-game-cued-fidelity branch for the two new options. Adds reference-game-hawkins-local.html, the vendored offline twin, since the CDN build cannot run until the plugin publishes to npm; vendor/ is gitignored. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 + reference-game-hawkins-local.html | 474 ++++++++++++++++++++++++++++++ reference-game-hawkins.html | 246 +++++++++++++++- 3 files changed, 716 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 reference-game-hawkins-local.html 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