Skip to content

Add jsPsych.multiplayer core API (MultiplayerAPI + MultiplayerAdapter interface) - #3694

Open
htsukamoto5 wants to merge 55 commits into
jspsych:mainfrom
htsukamoto5:multiplayer
Open

htsukamoto5 wants to merge 55 commits into
jspsych:mainfrom
htsukamoto5:multiplayer

Conversation

@htsukamoto5

@htsukamoto5 htsukamoto5 commented Jun 25, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds jsPsych.multiplayer — an opt-in, backend-agnostic API for real-time multiplayer experiments. This PR is core-only: it introduces the MultiplayerAPI module and the MultiplayerAdapter interface that any network backend (JATOS, Firebase, a custom WebSocket server, etc.) implements. No plugin, adapter implementation, or demo is included here — those live in a sibling repo and will follow in separate PRs once this core surface lands.

API surface

jsPsych.multiplayer is a small state primitive plus conveniences, organized like jsPsych.data:

  • State primitive: push(data), getAll(), subscribe(callback) — one shared group-session "whiteboard", three verbs. subscribe() replays the current snapshot once on registration before forwarding future updates.
  • Conveniences built on the primitive: update(data) (shallow get→merge→push), get(participantId) (single-participant read), wait(condition, timeout?) (promise that resolves when a predicate over the group session becomes true, rejecting with MultiplayerTimeoutError on timeout).
  • Lifecycle: connect(adapter), disconnect(), cancelAllSubscriptions(), and a participantId property.

The MultiplayerAdapter interface is intentionally minimal — connect, push, getAll, get, subscribe, disconnect — so a backend only needs to implement network I/O. All higher-level behavior (subscribe replay, wait fast-path, subscription tracking) lives in MultiplayerAPI so it's consistent across backends.

MultiplayerTimeoutError is a new export from the jspsych package, re-exported top-level alongside the other multiplayer types.

What's included

  • Core API (packages/jspsych): MultiplayerAPI class and MultiplayerAdapter interface at src/modules/multiplayer/, exposed as jsPsych.multiplayer
  • Tests: core API tests in packages/jspsych/tests/multiplayer/multiplayer.test.ts
  • Docs: docs/reference/jspsych-multiplayer.md, docs/developers/adapter-development.md, wired into mkdocs.yml
  • Changeset: minor bump for jspsych

Design notes

The adapter's subscribe() is future-only; replay is handled once in MultiplayerAPI.subscribe() (register-then-replay ordering prevents a TDZ crash when wait() references its own unsubscribe handle inside the callback).

update(data) shallow-merges into the caller's own participant slot before pushing — conflict-freedom relies on each participant only ever writing their own slot, so no version/timestamp logic is needed in core.

A throwing subscribe() callback is caught and logged rather than aborting notification of other subscribers on the same adapter fan-out.

Out of scope here

  • @jspsych/plugin-multiplayer-sync and @jspsych/adapter-multiplayer-jatos — moved to a sibling repo and will be proposed as follow-up PRs once this core API is stable
  • Participant dropout / presence — no presence primitive yet; a peer disconnecting isn't surfaced to the API
  • A lightweight ephemeral event channel (publish/onMessage) for transient/high-rate messages, deferred pending a concrete use case
  • Wiring cancelAllSubscriptions() into abortExperiment() teardown

Test plan

  • npm test in packages/jspsych — multiplayer tests pass

🤖 Generated with Claude Code

htsukamoto5 and others added 25 commits June 17, 2026 11:38
Adds MultiplayerAPI to plugin-api (Connect/Push/Get/Wait/Subscribe/Communicate
primitives, adapter pattern, subscription lifecycle tracking) and a
JatosAdapter package that implements the interface via JATOS group studies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add 6 missing test cases: getAll, wait fast-path, subscribe fires,
disconnect state reset, pre-connect error guards, and double-connect error.
Move "types": ["jest"] into compilerOptions where TypeScript expects it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- tsconfig: restore @types/node by using ["jest", "node"] so tsc passes
- MultiplayerAPI.connect() now rolls back adapter/participantId on failure
- add test for failed-connect rollback and retry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…TOS 3.x

- Use .NET ZipFile API on Windows to write forward-slash entry names
  (Compress-Archive produces backslashes which violate the ZIP spec)
- Rename metadata file from jatos_study_metadata.json to .jas extension,
  which is what JATOS 3.x import actually scans for
- Add required uuid fields on study, component, and batch
- Add missing JATOS 3.x fields: linearStudy, allowPreview, studyEntryMsg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Packages the multiplayer push -> wait pattern as a single declarative
trial, replacing the call-function (async/done) and NO_KEYS
html-keyboard-response + on_start + manual finishTrial idioms.

Params: wait_for (required), push_data, message, timeout, on_timeout,
minimum_wait. Data: group, wait_time, timed_out. Built on the existing
multiplayer plugin API; relies on jsPsych dynamic parameters so
push_data/message accept function forms.

Includes 4 jest tests (in-memory MockAdapter), README, and a rewritten
ultimatum-game example using the plugin for all sync points.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Point the JATOS archive builder at multiplayer-ultimatum-game-sync.html:
drop the plugin-call-function bundle (no longer used) and add the
plugin-multiplayer-sync bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the 2-player-cap behavior from multiplayer-ultimatum-game.html: a
3rd+ participant sets gameIsFull in the lobby on_finish, disconnects,
and is routed to a "game full" screen; the game timeline is gated on
!gameIsFull. Keeps both example versions in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Windows branch that zips via .NET ZipFile with forward-slash
entry names (matching build-jatos-ultimatum.js), so the archive imports
correctly on Windows. The existing zip -r path still serves macOS/Linux.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug 1 — players who joined before the host were invisible. The host
subscribed to the group session but subscribe() only fires on future
updates, so anyone already sitting in the lobby barrier never triggered
a render. The host now renders the current snapshot once right after
subscribing.

Bug 2 — players could be stranded forever on a loading screen. Barriers
waited on exact phase equality (e.g. phase === REVEAL), but JATOS does
not guarantee a client observes every intermediate snapshot, so a
skipped phase made the condition permanently unsatisfiable. Introduce a
monotonic step counter (phaseStep) in protocol.js: the host stamps every
push with a step that only increases, and players wait with
hostStepValue(group) >= phaseStep(...). A >= test on a monotonic value
can never be missed.

Applied across all three example files (index.html is the one the JATOS
build ships; host.html/player.html are the split versions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers the full MultiplayerAdapter contract against a mock of the jatos
global: construction (missing-global error, participantId derivation),
the connect handshake (resolve on open, reject on error), getAll/get
reads, subscribe fan-out + unsubscribe, and push — including the
optimistic-concurrency retry path (retries a version conflict to success,
and throws after exhausting all 8 attempts), driven with fake timers.

Documents that the adapter's subscribe() is intentionally future-only;
whether core should replay current state on subscribe is a separate open
question deferred pending maintainer input.

No changeset: test-only change, no published behavior affected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
subscribe() now emits the current group session snapshot once synchronously
on registration (register-then-replay ordering), matching Firebase onSnapshot
semantics and eliminating the asymmetry with wait()'s existing fast-path.
The replay fires after the unsubscribe handle is built, preventing the TDZ
crash that would occur if wait()'s callback tried to call unsubscribe() before
it was assigned. Two core tests updated to account for the leading replay emit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Delete host.html and player.html — both were stale divergent copies of
the host and player flows that had since been fixed and unified into
index.html. The jzip build already shipped index.html as the sole
component; the deleted files were never referenced by the build script.

Also clarify the adapter.subscribe() comment in index.html (replay is a
MultiplayerAPI wrapper concern, not the raw adapter's) and add a
stale-dist warning to build-jatos-kahoot.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three new pages:
- docs/plugins/multiplayer-sync.md — plugin-style reference with parameters,
  data-generated, install snippet, and three usage examples
- docs/reference/jspsych-multiplayer.md — full MultiplayerAPI method reference
  (connect, disconnect, push, get, getAll, subscribe, wait, communicate,
  cancelAllSubscriptions, participantId)
- docs/developers/adapter-development.md — adapter authoring guide with the
  full MultiplayerAdapter interface contract, a minimal in-memory example,
  a pointer to the JATOS adapter as a real-world reference, and a checklist

mkdocs.yml: wired all three pages into Plugins, Reference, and Developers nav.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renames examples/kahoot → examples/group-quiz, the build script, and
the docs plan file. Updates all internal references (study title, dir
name, zip name, asset paths, heading). Replaces context-specific
trivia questions with general-knowledge questions. Switches the color
scheme from Kahoot purple/red/blue/yellow/green to dark teal with
blue/amber/violet/emerald answer tiles, and replaces ▲◆●■ symbols
with A/B/C/D labels. Removes the stale root-level kahoot-plan.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1c3623b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
jspsych Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

jodeleeuw added a commit that referenced this pull request Jun 26, 2026
The fork-PR fallback used listPullRequestsAssociatedWithCommit, which
returns an empty list for fork-PR head commits (they don't live in a
base-repo branch). The publish job therefore failed with "No open PR
found" before it could push the preview branch or post the comment, so
fork PRs (e.g. #3694) never received a preview comment.

Look the PR up by its head ref (headOwner:headBranch) from the trusted
workflow_run payload via pulls.list instead. The PR number and head SHA
are still sourced only from the trusted event payload and validated
downstream, so trust guarantees are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

📦 Preview build ready

Built from PR head 1c3623b and published at 1e77da4 on branch preview/pr-3694.
URLs below are pinned to an immutable commit SHA, so they are safe to share and are cached permanently by jsDelivr.

Changed packages: jspsych

Quick-start HTML:

<script src="https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/jspsych/dist/index.browser.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/jspsych/css/jspsych.css">
<script src="https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-html-keyboard-response/dist/index.browser.min.js"></script>
All package URLs
  • @jspsych/extension-mouse-tracking → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/extension-mouse-tracking/dist/index.browser.min.js
  • @jspsych/extension-record-video → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/extension-record-video/dist/index.browser.min.js
  • @jspsych/extension-webgazer → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/extension-webgazer/dist/index.browser.min.js
  • jspsych → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/jspsych/dist/index.browser.min.js
  • @jspsych/plugin-animation → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-animation/dist/index.browser.min.js
  • @jspsych/plugin-audio-button-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-audio-button-response/dist/index.browser.min.js
  • @jspsych/plugin-audio-keyboard-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-audio-keyboard-response/dist/index.browser.min.js
  • @jspsych/plugin-audio-slider-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-audio-slider-response/dist/index.browser.min.js
  • @jspsych/plugin-browser-check → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-browser-check/dist/index.browser.min.js
  • @jspsych/plugin-call-function → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-call-function/dist/index.browser.min.js
  • @jspsych/plugin-canvas-button-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-canvas-button-response/dist/index.browser.min.js
  • @jspsych/plugin-canvas-keyboard-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-canvas-keyboard-response/dist/index.browser.min.js
  • @jspsych/plugin-canvas-slider-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-canvas-slider-response/dist/index.browser.min.js
  • @jspsych/plugin-categorize-animation → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-categorize-animation/dist/index.browser.min.js
  • @jspsych/plugin-categorize-html → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-categorize-html/dist/index.browser.min.js
  • @jspsych/plugin-categorize-image → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-categorize-image/dist/index.browser.min.js
  • @jspsych/plugin-cloze → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-cloze/dist/index.browser.min.js
  • @jspsych/plugin-external-html → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-external-html/dist/index.browser.min.js
  • @jspsych/plugin-free-sort → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-free-sort/dist/index.browser.min.js
  • @jspsych/plugin-fullscreen → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-fullscreen/dist/index.browser.min.js
  • @jspsych/plugin-html-audio-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-html-audio-response/dist/index.browser.min.js
  • @jspsych/plugin-html-button-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-html-button-response/dist/index.browser.min.js
  • @jspsych/plugin-html-keyboard-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-html-keyboard-response/dist/index.browser.min.js
  • @jspsych/plugin-html-slider-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-html-slider-response/dist/index.browser.min.js
  • @jspsych/plugin-html-video-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-html-video-response/dist/index.browser.min.js
  • @jspsych/plugin-iat-html → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-iat-html/dist/index.browser.min.js
  • @jspsych/plugin-iat-image → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-iat-image/dist/index.browser.min.js
  • @jspsych/plugin-image-button-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-image-button-response/dist/index.browser.min.js
  • @jspsych/plugin-image-keyboard-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-image-keyboard-response/dist/index.browser.min.js
  • @jspsych/plugin-image-slider-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-image-slider-response/dist/index.browser.min.js
  • @jspsych/plugin-initialize-camera → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-initialize-camera/dist/index.browser.min.js
  • @jspsych/plugin-initialize-microphone → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-initialize-microphone/dist/index.browser.min.js
  • @jspsych/plugin-instructions → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-instructions/dist/index.browser.min.js
  • @jspsych/plugin-maxdiff → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-maxdiff/dist/index.browser.min.js
  • @jspsych/plugin-mirror-camera → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-mirror-camera/dist/index.browser.min.js
  • @jspsych/plugin-preload → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-preload/dist/index.browser.min.js
  • @jspsych/plugin-reconstruction → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-reconstruction/dist/index.browser.min.js
  • @jspsych/plugin-resize → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-resize/dist/index.browser.min.js
  • @jspsych/plugin-same-different-html → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-same-different-html/dist/index.browser.min.js
  • @jspsych/plugin-same-different-image → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-same-different-image/dist/index.browser.min.js
  • @jspsych/plugin-serial-reaction-time-mouse → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-serial-reaction-time-mouse/dist/index.browser.min.js
  • @jspsych/plugin-serial-reaction-time → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-serial-reaction-time/dist/index.browser.min.js
  • @jspsych/plugin-sketchpad → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-sketchpad/dist/index.browser.min.js
  • @jspsych/plugin-survey-html-form → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-survey-html-form/dist/index.browser.min.js
  • @jspsych/plugin-survey-likert → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-survey-likert/dist/index.browser.min.js
  • @jspsych/plugin-survey-multi-choice → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-survey-multi-choice/dist/index.browser.min.js
  • @jspsych/plugin-survey-multi-select → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-survey-multi-select/dist/index.browser.min.js
  • @jspsych/plugin-survey-text → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-survey-text/dist/index.browser.min.js
  • @jspsych/plugin-survey → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-survey/dist/index.browser.min.js
  • @jspsych/plugin-video-button-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-video-button-response/dist/index.browser.min.js
  • @jspsych/plugin-video-keyboard-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-video-keyboard-response/dist/index.browser.min.js
  • @jspsych/plugin-video-slider-response → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-video-slider-response/dist/index.browser.min.js
  • @jspsych/plugin-virtual-chinrest → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-virtual-chinrest/dist/index.browser.min.js
  • @jspsych/plugin-visual-search-circle → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-visual-search-circle/dist/index.browser.min.js
  • @jspsych/plugin-webgazer-calibrate → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-webgazer-calibrate/dist/index.browser.min.js
  • @jspsych/plugin-webgazer-init-camera → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-webgazer-init-camera/dist/index.browser.min.js
  • @jspsych/plugin-webgazer-validate → https://cdn.jsdelivr.net/gh/jspsych/jsPsych@1e77da41966f22832ee0d133d137d81b3e50639a/packages/plugin-webgazer-validate/dist/index.browser.min.js

Last updated 2026-09-28 14:10 UTC for PR head 1c3623b.

github-actions Bot pushed a commit that referenced this pull request Sep 17, 2026
… them

Updates made while a write is in flight now merge into a single follow-up
push and share its promise, so a trial that updates faster than the backend
confirms writes (e.g. a drawing plugin pushing every 60ms) can't build an
unbounded queue behind a slow push. Later calls win per key; ordering and the
merge base are unchanged.

An idle update() also reaches the adapter synchronously rather than a
microtask later, and disconnect() now rejects pending and in-flight batches
and abandons the push loop, so their callers don't wait forever and a
subsequent connect() isn't stuck behind an unresolved push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Redesign the multiplayer contract before release, replacing piecemeal
fixes for the races found in review with a structure that rules them out.

- connect() returns a MultiplayerSession that owns all per-connection
  state; jsPsych.multiplayer forwards to the current session. connect()
  takes an AbortSignal, and disconnect() during a pending connect waits
  until the adapter has closed what it opened.
- Adapters become factories: adapter.connect() returns a new
  MultiplayerConnection and reports changes through onChange()/onStatus()
  callbacks, so reusing an adapter object can't share state.
- The session owns this participant's slot. push()/update() change it at
  once (as a frozen JSON copy) and sendLatestSlot() keeps one push in
  flight, so writes reach the backend in call order and the adapter never
  sees the caller's object. Unchanged writes send and notify nothing.
- Readers share one frozen snapshot per change, with the local slot on
  top; subscribers fire on local writes and get presence too.
- Presence: participants are connected/away/left, with a dropoutTimeout
  (default 10s) paused while this client reconnects. wait() takes
  { timeout, signal, participants } and rejects with
  MultiplayerParticipantLeftError; a lost connection rejects pending work
  with MultiplayerConnectionClosedError.
- jsPsych cancels subscriptions in a finally before on_finish.

BREAKING CHANGE: the MultiplayerAdapter interface, wait()'s second
argument, and snapshot mutability all change; adapters and plugins in
jspsych-multiplayer need updating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Sep 24, 2026
…tract

Use "shared data" for what all participants read and "session" for one
connection, which the pages previously both called a session. Note that
the module only copies getAll() and notifies subscribers when data
changed, point JATOS adapters at jatos.groupChannels, and edit both
pages for clarity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Sep 24, 2026
… timeout

- When a session closes, by disconnect() or a lost connection, call each
  subscriber one last time (own presence "left") before removing it, so
  plugins that only subscribe learn the connection is gone.
- wait(condition, 30000), the pre-redesign form, now rejects with a
  TypeError instead of silently meaning no timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Sep 24, 2026
A participant who had `left` becomes `connected` again when they come
back from the same page load, so their experiment is still in step
(onParticipantRejoined). One who reloads under the same ID has restarted
the experiment, so they stay `left` (onParticipantRestarted), and their
own page sees previousInstance.

Each jsPsych.multiplayer keeps a random per-page instance id and an epoch
it bumps on connect and on every recovery, written into the slot under
the reserved key `$mp` and removed from every snapshot. A returning
participant counts as back only after a write made since they dropped,
since presence alone can't tell a reconnect from a reloaded page that
hasn't written yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Sep 24, 2026
Adapters now report a sessionId on each connection: the same for every
participant in the group, stable across reconnects and reloads, and
different for each group. connect() rejects a connection without one.

jsPsych.multiplayer gains random(key), randomInt(key, lower, upper),
shuffle(key, array), and sample(key, array, size). Each value is a pure
function of the session's seed, the method, and a caller-named key, so
every participant gets the same values without sending anything, and
call order, extra calls, and reloads don't matter. The seed is the
session ID, or the new randomSeed connect option for values that are
the same in every session. A golden-value test pins the algorithm
(cyrb128 + sfc32), since changing it is a breaking change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jodeleeuw and others added 2 commits September 24, 2026 18:49
Backends that put arriving participants into groups can now report the
group through an optional connection.group() returning { size, members,
sealed }, and seal it early through an optional connection.sealGroup().

A group is forming until it is sealed: before that, a member who leaves
frees their place; after it, the roster is final and a departure is a
dropout. jsPsych.multiplayer gains group(), sealGroup(), and
waitForGroup({ timeout, signal }) for waiting rooms, and subscribers and
wait() conditions get the group as a third argument.

Some backends (JATOS) tell only the member who sealed the group, so the
session carries the roster in the reserved $mp key. Any member's roster
seals the group for everyone, the roster only grows, and a reloaded page
picks it up from its own old slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A member the backend assigned to the group who never connects was never
seen, so they never became left and a gate waiting on them could hang.
Presence now covers the whole sealed roster: an unseen member starts out
away and becomes left after the dropout timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jodeleeuw and others added 6 commits September 25, 2026 11:26
- One MultiplayerError class with a code, in place of four error classes
- update() is the write; push() becomes replace(); reads work after disconnect
- Trial scopes: a trial's data lives in its own scope, named by timeline
  position or the new multiplayer_scope parameter; { scope: "session" }
  opts out. Subscriptions and waits made during a trial end with it.
- left is final and group-agreed; a participant the group gave up on is
  told its connection was lost. Drops onParticipantRejoined/Restarted;
  previousInstance becomes restarted: boolean
- Failed pushes retry with backoff instead of leaving the group behind
- Timeouts: null means none, a positive number is ms, anything else throws
- Adapters relay seals through group(); peers' data can no longer seal the
  group or add members. getAll() shows only members' data when grouped.
- $mp bookkeeping carries a protocol version
- connectTimeout and reconnectTimeout move into core; adapters get onResumed()
- recordIds (default on) adds participant and session IDs to every data row
- session getter and cancelAllSubscriptions() leave the public API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck writes

- A participant the group counted as left who connects again from the
  same page is told the connection was lost, like one who reloads
- Writes that keep failing log one error once retries reach the longest
  delay, since retrying can't fix a write the backend always refuses
- multiplayer_scope accepts numbers, so a numeric timeline variable works

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A failed group() read keeps the last group state instead of unsealing
  the group and dropping former members' data
- sealGroup() can be retried after an adapter throws synchronously
- Scope names like "constructor" or "__proto__" no longer read
  Object.prototype
- recordIds tags each row as it is recorded, so rows keep the IDs of the
  session they came from after a reconnect
- A reconnect checks at once whether the group counted this participant
  as left; the experiment ending clears the trial scope

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a sealed roster, a participant is tracked only once this client
has seen them connected. Data left behind by someone who was gone before
this participant arrived, e.g. from an earlier session on the same link,
no longer shows in presence() or getAll(), and can't fire
onParticipantLeft ten seconds after joining. Sealed rosters still track
members who never connect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants