Add jsPsych.multiplayer core API (MultiplayerAPI + MultiplayerAdapter interface) - #3694
Open
htsukamoto5 wants to merge 55 commits into
Open
htsukamoto5 wants to merge 55 commits into
htsukamoto5 wants to merge 55 commits into
Conversation
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>
…in-multiplayer-sync
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 detectedLatest commit: 1c3623b The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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>
Contributor
📦 Preview build readyBuilt from PR head Changed packages: 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
Last updated 2026-09-28 14:10 UTC for PR head |
… 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>
…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>
… 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>
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>
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>
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>
- 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
jsPsych.multiplayer— an opt-in, backend-agnostic API for real-time multiplayer experiments. This PR is core-only: it introduces theMultiplayerAPImodule and theMultiplayerAdapterinterface 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.multiplayeris a small state primitive plus conveniences, organized likejsPsych.data:push(data),getAll(),subscribe(callback)— one shared group-session "whiteboard", three verbs.subscribe()replays the current snapshot once on registration before forwarding future updates.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 withMultiplayerTimeoutErroron timeout).connect(adapter),disconnect(),cancelAllSubscriptions(), and aparticipantIdproperty.The
MultiplayerAdapterinterface 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 inMultiplayerAPIso it's consistent across backends.MultiplayerTimeoutErroris a new export from thejspsychpackage, re-exported top-level alongside the other multiplayer types.What's included
packages/jspsych):MultiplayerAPIclass andMultiplayerAdapterinterface atsrc/modules/multiplayer/, exposed asjsPsych.multiplayerpackages/jspsych/tests/multiplayer/multiplayer.test.tsdocs/reference/jspsych-multiplayer.md,docs/developers/adapter-development.md, wired intomkdocs.ymljspsychDesign notes
The adapter's
subscribe()is future-only; replay is handled once inMultiplayerAPI.subscribe()(register-then-replay ordering prevents a TDZ crash whenwait()references its ownunsubscribehandle 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-syncand@jspsych/adapter-multiplayer-jatos— moved to a sibling repo and will be proposed as follow-up PRs once this core API is stablepublish/onMessage) for transient/high-rate messages, deferred pending a concrete use casecancelAllSubscriptions()intoabortExperiment()teardownTest plan
npm testinpackages/jspsych— multiplayer tests pass🤖 Generated with Claude Code