@jspsych-multiplayer/plugin-multiplayer-sync
v0.1.0
Published
jsPsych plugin: synchronization barrier (push → wait) for the multiplayer API
Readme
@jspsych-multiplayer/plugin-multiplayer-sync
A synchronization-barrier plugin for multiplayer jsPsych experiments, built on the multiplayer plugin API. It packages the common push → wait pattern into a single declarative trial: optionally push this participant's data into the shared group session, show a waiting message, and end the trial once a condition over the group session is met (or an optional timeout elapses).
This replaces the awkward idioms previously needed for synchronization points — a call-function trial with async/done, or an html-keyboard-response trial with choices: "NO_KEYS", an on_start that awaits pluginAPI.wait(), and a manual jsPsych.finishTrial().
Status: built against the jsPsych multiplayer API from jsPsych#3694, which is not yet released. The plugin codes against a local interface mirroring that API (
src/multiplayer-api.ts) and reaches the real object with one cast — the single seam to re-verify once #3694 lands. Tests run against an in-memory mock, so no live group session is needed.
Prerequisites
Requires a connected multiplayer adapter (e.g. @jspsych-multiplayer/adapter-multiplayer-jatos). Connect it before jsPsych.run():
const jsPsych = initJsPsych();
await jsPsych.pluginAPI.connect(new jsPsychAdapterMultiplayerJatos());
await jsPsych.run(timeline);Parameters
| Parameter | Type | Default | Description |
| -------------- | ----------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| wait_for | function | undefined (required) | Predicate (group) => boolean evaluated against the full group session on every update. The trial ends when it returns true. Same condition you would pass to pluginAPI.wait(). |
| push_data | object | function | null | Data pushed into the group session when the trial starts, before waiting. null waits without pushing. May be a function returning the object, e.g. () => ({ offer }). |
| message | HTML string | function | "<p>Waiting for other players…</p>" | Shown while waiting. |
| timeout | integer | null | Max time to wait, in ms. On elapse the trial ends with timed_out: true and on_timeout is called. null waits indefinitely. |
| on_timeout | function | null | Called if timeout elapses before wait_for is satisfied. |
| minimum_wait | integer | 0 | Minimum time, in ms, to keep the message on screen so it doesn't flash by — applies whether the trial ends because the condition is met or because the timeout elapses. |
Data Generated
| Name | Type | Description |
| ----------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| group | object | The full group session snapshot at the moment the condition was met (or the timeout fired). Read peers / assign roles from here in on_finish. |
| wait_time | integer | Time spent waiting, in ms, from trial start until the trial ended. |
| timed_out | boolean | True if the trial ended because timeout elapsed rather than because wait_for was met. |
| wait_error | string | null | Message from the wait() rejection when the trial ended without wait_for being met (on a genuine timeout, the timeout message); null when the condition was satisfied. |
Writing robust wait_for predicates
push uses overwrite-per-participant semantics: each participant has a single entry in the group session, and every push replaces it. A fast peer that clears a barrier and pushes again in a later trial can therefore overwrite the very entry your wait_for predicate is still checking — and the condition you were waiting for may never (re)appear.
Prefer predicates that are monotone: once true, they stay true under any later push. For example:
// Fragile: p2's next push may not include `status`, so this can flicker back to false
wait_for: (group) => group["p2"]?.status === "ready",
// Robust: presence in the session is monotone — participants don't disappear
wait_for: (group) => Object.keys(group).length >= 2,
// Robust: carry a monotone counter/phase forward in every push and compare with >=
wait_for: (group) => Object.values(group).every((p) => p.trial_index >= 5),If a barrier must key on transient fields, include them in every subsequent push (so they are never overwritten away), or advance a phase/counter field that only increases.
Example: a lobby that waits for two players
const lobby = {
type: jsPsychMultiplayerSync,
push_data: { status: "ready" },
wait_for: (group) => Object.keys(group).length >= 2,
message: "<p>Waiting for another player to join…</p>",
on_finish: (data) => {
// Role assignment stays experiment-specific — do it here off data.group, or hand the snapshot
// to @jspsych-multiplayer/plugin-multiplayer-role for deterministic consensus.
const [proposerId, responderId] = Object.keys(data.group).sort();
myRole = jsPsych.pluginAPI.participantId === proposerId ? "proposer" : "responder";
},
};Scope
A jsPsych plugin is a trial, so this plugin covers synchronization points that are their own timeline step (barriers, lobbies, send-then-wait handoffs). For communication in the middle of another interactive trial, use the raw jsPsych.pluginAPI (push, wait, get, getAll, subscribe, communicate) directly.
