Skip to content

Move adapters and plugins to the session-based multiplayer API, with presence - #94

Merged
jodeleeuw merged 33 commits into
mainfrom
core-sessions-presence
Sep 24, 2026
Merged

jodeleeuw merged 33 commits into
mainfrom
core-sessions-presence

Conversation

@jodeleeuw

Copy link
Copy Markdown
Member

Moves every adapter and plugin to the redesigned core multiplayer API in jsPsych#3694, and handles participants who drop out.

Depends on jsPsych#3694. Until a jsPsych release includes the API, jspsych is a vendored preview build (core ad80b345, preview a6e05be0) in vendor/jspsych. Refresh it with npm run vendor-jspsych <preview-sha>, and delete vendor/ and return to npm once the API is released.

What changed in the core API

  • connect() returns a session, and jsPsych.multiplayer forwards to the current session.
  • Adapters are factories: each connect() returns a new connection.
  • A participant's own writes show up at once, and the session sends one write at a time, combining writes made in the meantime.
  • Snapshots are frozen and shared between readers.
  • Presence: each participant is connected, away or left.
  • wait(condition, { timeout, signal, participants }) replaces the positional timeout, which the new core rejects.

Adapters

  • Local: presence from heartbeats plus pagehide. A crashed tab counts as gone after 70 s (presenceTimeoutMs), because Chrome slows timers in background tabs.
  • JATOS:
    • Presence comes from jatos.groupChannels and the member callbacks.
    • A connection that stays down for 30 s (closeAfterReconnectingMs) reports closed.
    • Reads return the last data seen while reconnecting.
    • A refused join now fails immediately.
    • Only one connection per page, because jatos.js keeps a single set of callbacks.
  • Firebase:
    • Presence uses a separate node (security rules updated), so a network interruption no longer deletes a participant's data slot.
    • Reconnects are reported as reconnecting, then connected.
    • A listener cancelled after connecting now reports closed instead of being silently ignored.

In all three, disconnect() keeps the participant's data slot; presence now tracks who is in the group.

Plugins

  • All plugins:
    • They use jsPsych.multiplayer directly; the per-package copies of the API types are deleted.
    • Configured timeouts work again.
    • Dropouts are recorded the same way everywhere: partner_left, left_participant and connection_lost, plus ended_by: "participant_left" | "connection_lost" in the live plugins.
  • Barrier plugins (ready, choice, match, role, scoreboard):
    • participants defaults to the other participants who are connected when the wait starts.
    • sync defaults to [] (ignore departures), because it is often used as a lobby.
  • match and role: they wait until every participant they count is connected, so each client computes the partition or role assignment over the same group.
  • Gate keys:
    • ready, choice and scoreboard default to a separate key for each gate (ready-1, ready-2, …), so flags from an earlier gate can't satisfy a later one.
    • Breaking: choice and scoreboard used to default to "choice" and "scoreboard".
    • ready now merges into the participant's slot instead of replacing it.
  • chat, draw and reference-game:
    • A new end_on_participant_left parameter (default true).
    • Subscriptions end when the trial ends, fixing two small leaks.
    • A lost connection ends the trial instead of hanging.

Tests, examples and docs

  • The hand-written mocks are replaced by test-utils/memory-backend.ts, which runs the real session over an in-memory connection. 652 tests pass.
  • Every example is pinned to the new preview, and lobbies count participants by presence. The group quiz uses jsPsych.multiplayer, and the JATOS adapter example now loads jatos.js (from fix: make the JATOS adapter example runnable, and use jsPsych.multiplayer in examples #71).
  • Docs: the API reference, introduction, "Choosing an adapter" and both tutorials are rewritten. A new "Handling dropouts" guide is added.
  • Changesets: pending changesets that described the old contract are corrected, and two that describe only removed code are deleted.

Not verified

The examples haven't been run in a browser yet.

Related PRs

🤖 Generated with Claude Code

jodeleeuw and others added 30 commits September 24, 2026 11:23
…dency

The multiplayer API isn't in a published jsPsych release yet. Vendor the
preview build of core ad80b345 (preview commit a6e05be0) so packages can
import the real types and tests can run the real session. Delete vendor/
and return to npm once a release includes the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Use jsPsych.multiplayer directly; drop the local API copy.
- Subscribe with an AbortController signal, render from the synchronous
  own-write notification (no optimistic render), and read own messages
  from the slot instead of a local array.
- End with ended_by "participant_left" (partner_left, left_participant)
  when a participant who was connected at the start leaves, unless
  end_on_participant_left is false; "connection_lost" when the session
  closes. Roster marks away/left participants.
- end_when and sender_label also receive presence.
- Tests run the real session over the in-memory backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Use jsPsych.multiplayer directly; drop the local API copy.
- wait() takes { timeout, participants }; wait_for gets (group, presence).
  Fixes timeouts, which the old positional form silently disabled.
- New participants parameter (default: others still present when the wait
  starts). A departure ends the trial with partner_left/left_participant; a
  lost connection ends it with connection_lost.
- Tests run the real session over the in-memory backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LocalAdapter holds configuration only; each connect() returns a new
  LocalConnection that owns its signal handler, heartbeat timer, and page
  listeners, and reports changes through options.onChange().
- Presence: each tab refreshes mp-presence:<session>:<pid> every
  heartbeatIntervalMs (default 2 s); connectedParticipants() lists keys
  fresher than presenceTimeoutMs (default 70 s, covering Chrome's
  once-a-minute timers in long-hidden tabs). pagehide and disconnect()
  remove the key at once; a bfcache pageshow restores it.
- disconnect() keeps the data slot; presence says who is still here.
- An injected signal is never closed by a connection, and
  ChangeSignal.onChange() returns a remover so closed connections leave
  no handlers behind.
- Drop the local copy of the adapter interface; import types from jspsych.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Split FirebaseAdapter into configuration plus a FirebaseConnection that
each connect() creates, so reconnecting with the same adapter shares no
state. Notify the core through onChange()/onStatus() instead of
adapter-side subscribe/get, and honor the connect() abort signal after
every await and during the first-snapshot wait.

Presence moves to a sibling <pathPrefix>-presence node, written on
connect and on every reconnect and removed by the server's onDisconnect,
with a second listener mirroring it into connectedParticipants(). Data
slots are no longer removed on disconnect or re-pushed after a blip, so
removeOnDisconnect is gone. .info/connected drives reconnecting and
connected, and a listener cancelled after connecting now reports closed
instead of being silently ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- connect(options) returns a new JatosConnection per call with getAll(),
  connectedParticipants() (jatos.groupChannels), push(), disconnect();
  adapter-side subscribe()/get() and the local interface copy are gone.
- Reports onChange() on group session and member join/leave/open/close
  events, and onStatus() reconnecting/connected when the channel drops and
  reopens; closed after closeAfterReconnectingMs (default 30000).
- Serves the last group session data while jatos.js has it wiped during a
  reconnect; push() waits for the channel to reopen.
- connect() honors the abort signal, uses the joinGroup() promise so a
  refused channel rejects promptly, and guards the page's single jatos.js
  group channel; a cancelled join that opens late leaves the group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Each gate marks readiness with its own key (data_key, default ready-1,
  ready-2, … per jsPsych instance), merged with update() so earlier gates'
  flags stay. A second gate no longer passes on leftover flags, and a fast
  participant can't remove a flag a slower one is still counting.
- push_data now merges into the slot instead of replacing it.
- Counts ignore participants who have left; new participants parameter;
  partner_left/left_participant/connection_lost data; data_key recorded.
- Use jsPsych.multiplayer directly and wait(cond, { timeout, participants }),
  which fixes timeouts the old positional form silently disabled.
- demo.html uses a new-contract localStorage adapter; tests run the real
  session over the in-memory backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Use jsPsych.multiplayer directly; drop the local API copy.
- Subscribe with an AbortController signal; keep the initial full
  repaint (the incremental painter skips our own slot) and overlay
  unwritten points on the last delivered snapshot.
- End with ended_by "participant_left" (partner_left, left_participant)
  unless end_on_participant_left is false, and "connection_lost" when
  the session closes; roster marks away/left participants.
- end_when and roster_label also receive presence.
- Copy strokes restored from the frozen slot (fixes redo throwing on a
  restored stroke) and copy strokes into trial data.
- Tests run the real session over the in-memory backend.

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

- data_key defaults to null, generating choice-1, choice-2, … per jsPsych
  instance, so a choice from an earlier trial can't count toward a later one.
  An explicit data_key is used as-is. The key is recorded as data_key.
- The barrier ignores participants who have left; the outcome still reports
  every choice made.
- New participants parameter; a departure or lost connection proceeds with
  the partial group, flagged partner_left/left_participant or connection_lost.
- Use jsPsych.multiplayer directly and wait(cond, { timeout, participants }),
  which fixes timeouts the old positional form silently disabled.
- Tests run the real session over the in-memory backend.

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

- Use jsPsych.multiplayer directly; drop the local API copy.
- Subscribe with an AbortController signal (fixes the listener leaked
  when the first callback ended the trial); read own chat and rounds
  from the latest snapshot instead of a local array; drop the
  optimistic render and optimistic enterFeedback, which the synchronous
  own-write notification now covers.
- End with ended_by "participant_left" (partner_left, left_participant)
  when the partner leaves before feedback, unless end_on_participant_left
  is false; a submission that arrived first still counts. End with
  "connection_lost" when the session closes.
- Partner auto-detect ignores participants who left and prefers
  connected ones.
- save_group stores a copy instead of the frozen snapshot.
- Tests run the real session over the in-memory backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Readiness and the partition ignore participants who have left.
- New participants parameter; a departure or lost connection ends the trial
  unmatched with partner_left/left_participant or connection_lost.
- A custom ready predicate gets (snapshot, presence); a throw still means
  "not ready" but the first one is logged, so a predicate that modifies the
  frozen snapshot no longer hangs silently.
- Use jsPsych.multiplayer directly and wait(cond, { timeout, participants });
  the 30 s default timeout, silently disabled by the old positional form, works
  again.
- Tests run the real session over the in-memory backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… never gets ready

A predicate that reads data before it arrives throws routinely, so logging
the first throw was noise. Report the last error on the unmatched paths
instead, where it may explain why the group never became ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Readiness and assignment ignore participants who have left.
- New participants parameter; a departure or lost connection ends the trial
  with role: null and partner_left/left_participant or connection_lost.
- A custom ready gets (snapshot, presence). Accessors still count a throw as
  "not ready", and the last error is now logged if the group never becomes
  ready, so an accessor that modifies the frozen snapshot is reported.
- Use jsPsych.multiplayer directly and wait(cond, { timeout, participants });
  the 30 s default timeout, silently disabled by the old positional form, works
  again.
- Tests run the real session over the in-memory backend.

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

- data_key defaults to null, generating scoreboard-1, scoreboard-2, … per
  jsPsych instance, so scores from an earlier board can't count toward a later
  one. An explicit data_key is used as-is. The key is recorded as data_key.
- The reporter count ignores participants who have left.
- New participants parameter; a departure or lost connection shows the
  partial board with partner_left/left_participant or connection_lost and a
  note.
- Replace the own-timer race and 2x backstop wait with the core's
  wait(cond, { timeout, participants }) and error-name matching.
- Tests run the real session over the in-memory backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Use jsPsych.multiplayer directly; drop the local API copy.
- Remove the subscription with an AbortController aborted at trial end,
  which also closes a leak when the first delivery ended the trial.
- Record connection_lost; the countdown keeps running locally.
- save_group reads the group safely when the session is gone.
- Tests run the real session over the in-memory backend.

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

Leftover slots from members who left earlier start out `away` and turn
`left` a few seconds after joining, so counting away participants made a
gate right after joining fail with partner_left. Match the live plugins,
which already count only connected participants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… from the HTML

The JATOS archive scripts matched the examples' jsPsych tags by their exact
unpkg URLs, which broke once the examples were pinned to a preview build.
They now find the jsPsych script and stylesheet in the HTML, so re-pinning
needs no script change, and the caveat printed after a build describes the
vendored preview instead of a missing API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Pin every example to the preview build of core ad80b345 (a6e05be0),
  including the ones that loaded an unpinned jsPsych without the API.
- Count lobbies and barriers by presence, since slots now outlive their
  participants, and give lobbies participants: [] so a departure doesn't
  end them.
- Drop the pagehide handlers that called the removed localAdapter.disconnect()
  and read participantId from jsPsych.multiplayer.
- Detect partner departures through presence in the ultimatum games,
  reference games and group quiz; keep timeouts as a backstop for a partner
  who stays connected but idle.
- Rewrite the group-quiz host on jsPsych.multiplayer instead of the raw
  JATOS adapter.
- Point examples/ultimatum-game-tutorial.md at the docs-site tutorial,
  which it had drifted from.
- Replace the examples README's preview-pin guidance with the vendored
  preview workflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rewrite the jsPsych.multiplayer reference and the introduction's API table
for the new contract, add a Handling dropouts guide covering presence, the
plugins' partner_left/connection_lost data, participants defaults, and
per-gate keys, add presence details to Choosing an adapter, move both
tutorials to presence-based dropout detection, and update the prerelease
announcement bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Edit pending changesets that described superseded behavior (JSON-copy
reads, update() merging onto the last write, the local API mirrors and
resolveMultiplayerApi, "any other rejection propagates") so the eventual
changelogs are accurate, and delete two that only described internal
changes the migration removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sync is often a lobby, which should keep waiting when someone leaves
rather than end with partner_left. Default participants to [] and keep
null for the dropout-aware barrier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jodeleeuw and others added 2 commits September 24, 2026 13:34
Each client computes the partition or role assignment on its own from
its own view of presence. Counting participants who were only away let a
leftover slot be matched or given a role, and let clients assign while
their views disagreed. Now the group isn't ready until every remaining
participant is connected.

Tests: add MemoryHub.addPeer()/removePeer() for connected participants
without a jsPsych instance; seed() stays an unconnected (away) slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JATOS doesn't inject jatos.js; the study page must load it. Also fix the
local adapter changelog's stale pluginAPI.connect() reference. Both fixes
come from #71 and #70, whose other changes this branch supersedes.

Co-authored-by: Hannah Tsukamoto <hannahtsukamoto555@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jodeleeuw pushed a commit that referenced this pull request Sep 24, 2026
Reapplied onto the session-based multiplayer API (#94); the feature
changes are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jodeleeuw pushed a commit that referenced this pull request Sep 24, 2026
Reapplied onto the session-based multiplayer API (#94); the feature
changes are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jodeleeuw pushed a commit that referenced this pull request Sep 24, 2026
Reapplied onto the session-based multiplayer API (#94).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jodeleeuw pushed a commit that referenced this pull request Sep 24, 2026
Reapplied onto the session-based multiplayer API (#94). The serialized
write queue is gone: update() merges only the typing key and the session
sends one write at a time, so a timestamp can't overwrite chat or round
data. The hint also hides while the partner is away or has left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a charset, servers that don't send one (e.g. python -m
http.server) decode the pages as Latin-1 and show "…" for "…".
The two replication examples are left alone because #69 removes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jodeleeuw
jodeleeuw merged commit fe4c62b into main Sep 24, 2026
6 checks passed
@jodeleeuw
jodeleeuw deleted the core-sessions-presence branch September 24, 2026 19:32
@jodeleeuw
jodeleeuw restored the core-sessions-presence branch September 24, 2026 19:33
@github-actions github-actions Bot mentioned this pull request Sep 24, 2026
@jodeleeuw
jodeleeuw deleted the core-sessions-presence branch September 24, 2026 19:33
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.

1 participant