Skip to main content

Getting started

Serve this file over https:// or from localhost and open it in Chrome or Edge.

<!doctype html>
<html>
<head>
<script src="https://unpkg.com/jspsych@8"></script>
<script src="https://unpkg.com/@jspsych/plugin-html-keyboard-response@2"></script>
<script src="https://unpkg.com/@saccadejs/core@0.1.0"></script>
<script src="https://unpkg.com/@saccadejs/extension@0.1.0"></script>
<script src="https://unpkg.com/@saccadejs/plugin-preview@0.1.0"></script>
<script src="https://unpkg.com/@saccadejs/plugin-time-sync@0.1.0"></script>
<script src="https://unpkg.com/@saccadejs/plugin-calibrate@0.1.0"></script>
<script src="https://unpkg.com/@saccadejs/plugin-validate@0.1.0"></script>
<link rel="stylesheet" href="https://unpkg.com/jspsych@8/css/jspsych.css" />
</head>
<body></body>
<script>
const jsPsych = initJsPsych({
extensions: [{ type: jsPsychExtensionSaccade }],
on_finish: () => jsPsych.data.displayData(),
});

const timeline = [
// Camera permission, model download, and head positioning.
{ type: jsPsychSaccadePreview },
// Measure this participant's screen-to-camera lag and apply it to every later `t`.
{ type: jsPsychSaccadeTimeSync },
// Fit the gaze model on 13 points, then check it on 9 held-out points.
{ type: jsPsychSaccadeCalibrate },
{ type: jsPsychSaccadeValidate },
// A trial that records gaze. `extensions` is what turns recording on.
{
type: jsPsychHtmlKeyboardResponse,
stimulus: `
<div style="display:flex; gap:20vw; justify-content:center">
<img id="left" src="left.png" width="300" />
<img id="right" src="right.png" width="300" />
</div>
<p>Look at whichever picture you prefer, then press any key.</p>`,
extensions: [
{ type: jsPsychExtensionSaccade, params: { targets: ["#left", "#right"] } },
],
},
];

jsPsych.run(timeline);
</script>
</html>

Load @saccadejs/core before the other packages, and pin every version.

The setup trials

  1. Register the extension

    Add it to initJsPsych once. This does not touch the camera.

  2. saccade-preview

    Asks for camera permission, downloads the model, and shows the participant the eye crop the model sees so they can position themselves. Put it early: this is where the download happens.

  3. saccade-time-sync

    Fifteen seconds of the screen stepping between black and white while the camera watches. This measures how far behind the camera clock is, and subtracts it from every later gaze timestamp. See Timing and synchrony.

  4. saccade-calibrate

    Thirteen points, about twenty seconds. Nothing predicts gaze until this finishes.

  5. saccade-validate

    Nine points the calibration never saw. Read median_error_viewport from its data and report it.

Then attach the extension to any trial you want gaze recorded on.

What the data looks like

The extension adds three fields to every trial it is attached to.

{
"rt": 3184,
"saccade_data": [
{ "x": 412, "y": 388, "t": 51 },
{ "x": 418, "y": 381, "t": 85 },
{ "x": 903, "y": 402, "t": 118 }
// one row per camera frame in which a face was found
],
"saccade_targets": {
"#left": { "x": 260, "y": 300, "width": 300, "height": 200,
"top": 300, "bottom": 500, "left": 260, "right": 560 },
"#right": { "x": 900, "y": 300, "width": 300, "height": 200,
"top": 300, "bottom": 500, "left": 900, "right": 1200 }
},
"saccade_timing": {
"offset_ms": 78, "corrected": true, "clock": "captureTime",
"dropped_frames": 1, "fps": 29.6, "tta": 5
}
}

x and y are viewport pixels in the participant's own window, so they hit-test directly against saccade_targets. t is milliseconds since the trial started, on the same clock as rt, with the measured timing offset already subtracted.

const data = jsPsych.data.get().filter({ trial_type: "html-keyboard-response" }).values()[0];
const inBox = (s, r) => s.x >= r.left && s.x <= r.right && s.y >= r.top && s.y <= r.bottom;

const left = data.saccade_data.filter((s) => inBox(s, data.saccade_targets["#left"])).length;
const right = data.saccade_data.filter((s) => inBox(s, data.saccade_targets["#right"])).length;

With npm and a bundler

npm install jspsych @saccadejs/extension @saccadejs/plugin-preview \
@saccadejs/plugin-time-sync @saccadejs/plugin-calibrate @saccadejs/plugin-validate
import { initJsPsych } from "jspsych";
import jsPsychExtensionSaccade from "@saccadejs/extension";
import jsPsychSaccadePreview from "@saccadejs/plugin-preview";
import jsPsychSaccadeTimeSync from "@saccadejs/plugin-time-sync";
import jsPsychSaccadeCalibrate from "@saccadejs/plugin-calibrate";
import jsPsychSaccadeValidate from "@saccadejs/plugin-validate";

The timeline is identical. @saccadejs/core arrives as a dependency of the others; install it explicitly only if you use the core API directly.

Without jsPsych

import { SaccadeTracker, runCalibration, defaultGrid13, runLoopback } from "@saccadejs/core";

const tracker = new SaccadeTracker();
await tracker.init(); // camera prompt and model load
document.body.appendChild(tracker.video);
tracker.start();

const { lagMs, verdict } = await runLoopback(tracker);

await runCalibration(tracker, defaultGrid13(), { settleMs: 1000, captureMs: 500 }, {
showTarget: (target, phase) => drawYourOwnTarget(target, phase),
});
tracker.fitCalibration();

tracker.onFrame((frame) => {
if (!frame.gaze) return;
const t = (frame.time.meanCapture ?? frame.time.capture) - (verdict === "OK" ? lagMs : 0);
console.log(frame.gaze.x, frame.gaze.y, t); // gaze is viewport fractions, 0-1
});

Next steps