Tobii Pro eye tracker integration for jsPsych experiments.
- Getting Started
- Architecture Overview
- Tutorial
- Calibration Guide
- Extension API Reference
- Troubleshooting
- Packages
- Development
- Modern web browser (Chrome, Firefox, Edge, Safari)
- Python 3.9 or higher
- Tobii Pro eye tracker (connected via USB or network)
- Node.js 18.0 or higher (optional — only needed if using npm)
pip install jspsych-tobiiAdd script tags to your HTML file. No build tools or Node.js installation needed.
<script src="https://unpkg.com/jspsych"></script>
<script src="https://unpkg.com/@jspsych/plugin-html-keyboard-response"></script>
<script src="https://unpkg.com/@jspsych/extension-tobii"></script>
<script src="https://unpkg.com/@jspsych/plugin-tobii-calibration"></script>
<script src="https://unpkg.com/@jspsych/plugin-tobii-validation"></script>
<script src="https://unpkg.com/@jspsych/plugin-tobii-user-position"></script>
<link rel="stylesheet" href="https://unpkg.com/jspsych/css/jspsych.css" />Alternative: Using npm
If you prefer a Node.js build workflow:
npm install @jspsych/extension-tobii \
@jspsych/plugin-tobii-calibration \
@jspsych/plugin-tobii-validation \
@jspsych/plugin-tobii-user-positionIn a terminal, start the Tobii WebSocket server:
jspsych-tobii-serverYou should see output like:
Starting Tobii WebSocket server on localhost:8080
Connected to tracker: {'model': 'Tobii Pro Spectrum', ...}
Server started successfully
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/jspsych"></script>
<script src="https://unpkg.com/@jspsych/plugin-html-keyboard-response"></script>
<script src="https://unpkg.com/@jspsych/extension-tobii"></script>
<script src="https://unpkg.com/@jspsych/plugin-tobii-calibration"></script>
<link rel="stylesheet" href="https://unpkg.com/jspsych/css/jspsych.css" />
</head>
<body>
<script>
const jsPsych = initJsPsych({
extensions: [
{
type: jsPsychExtensionTobii,
params: {
connection: {
url: 'ws://localhost:8080',
autoConnect: true,
},
},
},
],
});
const timeline = [];
// Add calibration
timeline.push({
type: jsPsychTobiiCalibration,
calibration_points: 9,
calibration_mode: 'view',
});
// Add your experimental trials with eye tracking enabled
timeline.push({
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p>Look at this text</p>',
extensions: [{ type: jsPsychExtensionTobii }],
});
jsPsych.run(timeline);
</script>
</body>
</html>Open the HTML file in your browser. The experiment will:
- Connect to the Tobii server
- Run calibration
- Collect eye tracking data during trials
- Store data in jsPsych's data structure
The jsPsych-Tobii integration consists of four JavaScript packages and one Python server that work together to enable eye tracking in jsPsych experiments.
+---------------------------------------------------------------------------+
| Browser |
| +---------------------------------------------------------------------+ |
| | jsPsych Timeline | |
| | | |
| | +------------------+ +-----------------+ +--------------------+ | |
| | | User Position | | Calibration | | Validation | | |
| | | Plugin | | Plugin | | Plugin | | |
| | +--------+---------+ +--------+--------+ +---------+---------+ | |
| | | | | | |
| | +---------------------+----------------------+ | |
| | | | |
| | +-------------v--------------+ | |
| | | Tobii Extension | | |
| | | (WebSocket + Data Mgmt) | | |
| | +-------------+--------------+ | |
| +---------------------------------------------------------------------+ |
+---------------------------------------------------------------------------+
| WebSocket
v
+---------------------------------------------------------------------------+
| Python WebSocket Server |
| (jspsych-tobii-server, port 8080) |
+---------------------------------------------------------------------------+
| USB/Network
v
+---------------------------------------------------------------------------+
| Tobii Eye Tracker |
| (Pro Spectrum, X3-120, Nano, etc.) |
+---------------------------------------------------------------------------+
| Package | Role | Depends On |
|---|---|---|
| @jspsych/extension-tobii | Core extension: WebSocket communication, time sync, gaze data buffering, coordinate utilities, data export | jsPsych >= 8.0.0 |
| @jspsych/plugin-tobii-calibration | Visual calibration procedure with animated points | extension-tobii |
| @jspsych/plugin-tobii-validation | Validates calibration accuracy with detailed metrics | extension-tobii |
| @jspsych/plugin-tobii-user-position | Real-time head position feedback | extension-tobii |
1. User Position -> Participant adjusts position until indicators are green
|
2. Calibration -> Eye tracker learns participant's gaze mapping
|
3. Validation -> Verify calibration is accurate (optional but recommended)
|
4. Experiment -> Collect gaze data during experimental trials
|
5. Data Export -> Export collected gaze data for analysis
Note: If you're using npm with ES module imports, replace the global variable references (e.g.,
jsPsychExtensionTobii) with the corresponding imports (e.g.,import TobiiExtension from '@jspsych/extension-tobii'). See the npm installation instructions for details.
const jsPsych = initJsPsych({
extensions: [
{
type: jsPsychExtensionTobii,
params: {
connection: {
url: 'ws://localhost:8080',
autoConnect: true,
},
},
},
],
});const timeline = [];
// Position guide (optional but recommended)
timeline.push({
type: jsPsychTobiiUserPosition,
message: 'Please adjust your position until both indicators are green',
require_good_position: true,
});
// Calibration
timeline.push({
type: jsPsychTobiiCalibration,
calibration_points: 9,
calibration_mode: 'view',
});
// Validation (recommended)
timeline.push({
type: jsPsychTobiiValidation,
validation_points: 9,
tolerance: 0.05,
});timeline.push({
type: jsPsychHtmlKeyboardResponse,
stimulus: '<img src="stimulus.png" />',
extensions: [{ type: jsPsychExtensionTobii }],
});jsPsych.run(timeline);A complete visual search experiment with eye tracking:
const jsPsych = initJsPsych({
extensions: [
{
type: jsPsychExtensionTobii,
params: {
connection: { url: 'ws://localhost:8080', autoConnect: true },
},
},
],
on_finish: () => {
const data = jsPsych.data.get().values();
jsPsych.extensions.tobii.exportToCSV(data, 'experiment_data.csv');
},
});
const timeline = [];
// Welcome screen
timeline.push({
type: jsPsychHtmlKeyboardResponse,
stimulus: `
<h1>Visual Search Experiment</h1>
<p>Press any key to begin the eye tracking setup.</p>
`,
});
// Position guide
timeline.push({
type: jsPsychTobiiUserPosition,
message: 'Adjust your position until both eye indicators are green, then click Continue.',
require_good_position: true,
});
// Calibration
timeline.push({
type: jsPsychTobiiCalibration,
calibration_points: 9,
calibration_mode: 'view',
});
// Validation
timeline.push({
type: jsPsychTobiiValidation,
validation_points: 9,
tolerance: 0.05,
});
// Experimental trials with eye tracking
const stimuli = [
{ target_present: true, set_size: 8 },
{ target_present: false, set_size: 8 },
{ target_present: true, set_size: 16 },
{ target_present: false, set_size: 16 },
];
for (const stim of stimuli) {
timeline.push({
type: jsPsychHtmlKeyboardResponse,
stimulus: generateSearchDisplay(stim),
choices: ['f', 'j'],
data: {
target_present: stim.target_present,
set_size: stim.set_size,
},
extensions: [{ type: jsPsychExtensionTobii }],
});
}
jsPsych.run(timeline);// Get current gaze position
const gaze = await jsPsych.extensions.tobii.getCurrentGaze();
console.log(`Looking at: (${gaze.x}, ${gaze.y})`);
// Get gaze data for a time range
const startTime = performance.now() - 1000;
const endTime = performance.now();
const gazeData = await jsPsych.extensions.tobii.getGazeData(startTime, endTime);// Convert normalized (0-1) to pixels
const pixels = jsPsych.extensions.tobii.normalizedToPixels(0.5, 0.5);
// { x: 960, y: 540 } for 1920x1080 screen
// Convert pixels to normalized
const normalized = jsPsych.extensions.tobii.pixelsToNormalized(960, 540);
// { x: 0.5, y: 0.5 }
// Get container-relative coordinates
const containerCoords = jsPsych.extensions.tobii.windowToContainer(gaze.x, gaze.y);const validationTrial = {
type: jsPsychTobiiValidation,
validation_points: 9,
tolerance: 0.05,
on_finish: (data) => {
if (!data.validation_success) {
jsPsych.addNodeToEndOfTimeline({
type: jsPsychTobiiCalibration,
calibration_points: 9,
});
jsPsych.addNodeToEndOfTimeline(validationTrial);
}
},
};Eye tracker calibration maps the physical characteristics of a participant's eyes to screen coordinates. Good calibration is essential for accurate gaze data.
Always use the position guide before calibration:
const positionTrial = {
type: jsPsychTobiiUserPosition,
message: 'Adjust your position until both eye indicators are green',
require_good_position: true,
show_distance_feedback: true,
show_position_feedback: true,
};The calibration plugin supports the following grid sizes:
| Grid Size | Pattern | Use Case |
|---|---|---|
| 5 | Corners + center | Quick calibration |
| 9 | 3x3 grid | Standard (recommended) |
| 13 | 3x3 outer + 4 diagonal midpoints | Enhanced coverage |
| 15 | 5 rows x 3 columns | Dense vertical coverage |
| 19 | Symmetric 3-5-3-5-3 pattern | High density |
| 25 | 5x5 full grid | Maximum coverage |
You can also provide custom points:
const calibrationTrial = {
type: jsPsychTobiiCalibration,
custom_points: [
{ x: 0.2, y: 0.2 },
{ x: 0.8, y: 0.2 },
{ x: 0.5, y: 0.5 },
{ x: 0.2, y: 0.8 },
{ x: 0.8, y: 0.8 },
],
};- Participant looks at each point as it appears
- Points appear with animation to guide attention
- Faster, less participant effort
- Best for experienced participants
- Participant clicks when ready at each point
- More control over timing
- Better for novice participants
const calibrationTrial = {
type: jsPsychTobiiCalibration,
calibration_mode: 'click',
button_text: 'Start Calibration',
};Always validate after calibration:
const validationTrial = {
type: jsPsychTobiiValidation,
validation_points: 9,
tolerance: 0.05,
show_feedback: true,
};The validation plugin reports:
- Average Accuracy: Mean distance between target and gaze (normalized, 0-1 scale)
- Average Precision: Consistency of gaze samples at each point (normalized)
- Per-point data: Accuracy, precision, mean gaze position, and sample counts for each validation point
| Tolerance | Use Case |
|---|---|
| 0.02 (2%) | High precision research |
| 0.05 (5%) | Standard experiments |
| 0.10 (10%) | Exploratory/pilot studies |
The calibration and validation plugins support built-in retry via the max_retries parameter. You can also implement custom recalibration logic:
const createCalibrationSequence = () => {
const sequence = [];
sequence.push({
type: jsPsychTobiiUserPosition,
require_good_position: true,
});
sequence.push({
type: jsPsychTobiiCalibration,
calibration_points: 9,
});
sequence.push({
type: jsPsychTobiiValidation,
validation_points: 9,
tolerance: 0.05,
on_finish: (data) => {
if (!data.validation_success) {
jsPsych.addNodeToEndOfTimeline({
timeline: createCalibrationSequence(),
});
}
},
});
return sequence;
};
timeline.push({ timeline: createCalibrationSequence() });- Lighting: Avoid direct light sources (windows, lamps) behind the participant
- Screen brightness: Use moderate brightness, avoid extreme settings
- Distance: Maintain optimal distance (typically 60-70cm for most trackers)
Tell participants to:
- Keep their head as still as possible
- Follow the dot smoothly with their eyes only
- Blink normally but not excessively
- Remove glasses if possible (or ensure clean lenses)
- Monitor refresh rate: Higher is better (60Hz minimum)
- Fullscreen mode: Always run experiments in fullscreen
- Warm-up time: Let the eye tracker warm up for a few minutes
- High calibration error: Use position guide first, increase
point_duration, try click mode, check lighting - Inconsistent results: Ensure participant isn't moving, check glasses/contacts, verify tracker position
- Points in certain areas fail: Check visibility, verify monitor calibration, consider custom points avoiding problem areas
All utility functions are exposed as methods on the extension instance, accessible via jsPsych.extensions.tobii.*.
Connect to the WebSocket server.
await jsPsych.extensions.tobii.connect();Returns: Promise<void>
Disconnect from the WebSocket server. Stops tracking if active.
await jsPsych.extensions.tobii.disconnect();Returns: Promise<void>
Check if connected to server.
const connected = jsPsych.extensions.tobii.isConnected();Returns: boolean
Get detailed connection status.
const status = jsPsych.extensions.tobii.getConnectionStatus();
// { connected: true, tracking: false, connectedAt: 1234567890 }Returns: ConnectionStatus with fields:
connected(boolean) - Connected to servertracking(boolean) - Currently trackinglastError(string, optional) - Last error messageconnectedAt(number, optional) - Connection timestamp
Start eye tracking data collection.
await jsPsych.extensions.tobii.startTracking();Returns: Promise<void>
Stop eye tracking data collection.
await jsPsych.extensions.tobii.stopTracking();Returns: Promise<void>
Check if currently tracking.
const tracking = jsPsych.extensions.tobii.isTracking();Returns: boolean
Start calibration procedure on the server.
await jsPsych.extensions.tobii.startCalibration();Returns: Promise<void>
Collect calibration data for a specific point.
const result = await jsPsych.extensions.tobii.collectCalibrationPoint(0.5, 0.5);Parameters:
x(number) - Normalized x coordinate (0-1)y(number) - Normalized y coordinate (0-1)
Returns: Promise<{ success: boolean }>
Compute calibration from collected points.
const result = await jsPsych.extensions.tobii.computeCalibration();
// { success: true }Returns: Promise<CalibrationResult>
Get calibration data and quality metrics.
const data = await jsPsych.extensions.tobii.getCalibrationData();Returns: Promise<CalibrationResult>
Start validation procedure on the server.
await jsPsych.extensions.tobii.startValidation();Returns: Promise<void>
Collect validation data for a specific point.
await jsPsych.extensions.tobii.collectValidationPoint(0.5, 0.5, gazeSamples);Parameters:
x(number) - Normalized x coordinate (0-1)y(number) - Normalized y coordinate (0-1)gazeSamples(GazeData[], optional) - Array of gaze samples collected at this point
Returns: Promise<void>
Compute validation metrics from collected points.
const result = await jsPsych.extensions.tobii.computeValidation();
// {
// success: true,
// averageAccuracy: 0.65,
// averagePrecision: 0.42,
// pointData: [...]
// }Returns: Promise<ValidationResult> with fields:
success(boolean) - Whether validation succeededaverageAccuracy(number) - Average accuracy in degreesaveragePrecision(number) - Average precision in degreespointData(array) - Per-point accuracy and precision dataerror(string, optional) - Error message if failed
Get current gaze position (from local buffer or server).
const gaze = await jsPsych.extensions.tobii.getCurrentGaze();
// { x: 0.52, y: 0.48, timestamp: 12345.67, leftValid: true, rightValid: true }Returns: Promise<GazeData | null>
GazeData fields:
x(number) - X coordinate (normalized 0-1 or pixels)y(number) - Y coordinate (normalized 0-1 or pixels)timestamp(number) - Device clock timestamp in msserverTimestamp(number, optional) - Python server clock timestampclientTimestamp(number, optional) - Browserperformance.now()when receivedleftValid(boolean, optional) - Left eye data validityrightValid(boolean, optional) - Right eye data validityleftPupilDiameter(number, optional) - Left eye pupil diameterrightPupilDiameter(number, optional) - Right eye pupil diameter
Get gaze data for a specific time range.
const startTime = performance.now() - 1000;
const endTime = performance.now();
const data = await jsPsych.extensions.tobii.getGazeData(startTime, endTime);Parameters:
startTime(number) - Start timestamp in millisecondsendTime(number) - End timestamp in milliseconds
Returns: Promise<GazeData[]>
Get recent gaze data from the buffer.
const recentData = jsPsych.extensions.tobii.getRecentGazeData(1000); // last 1 secondParameters:
durationMs(number) - How many milliseconds of recent data to retrieve
Returns: GazeData[]
Get current user position (head position).
const position = await jsPsych.extensions.tobii.getUserPosition();Returns: Promise<UserPositionData | null> with fields:
leftX,leftY,leftZ(number | null) - Left eye position (normalized 0-1, 0.5 is center/optimal)rightX,rightY,rightZ(number | null) - Right eye position (normalized 0-1)leftValid,rightValid(boolean) - Eye data validityleftOriginX,leftOriginY,leftOriginZ(number | null, optional) - Raw left eye origin in mmrightOriginX,rightOriginY,rightOriginZ(number | null, optional) - Raw right eye origin in mm
Clear stored gaze data from the buffer.
jsPsych.extensions.tobii.clearGazeData();Returns: void
Convert normalized coordinates (0-1) to window pixels.
const pixels = jsPsych.extensions.tobii.normalizedToPixels(0.5, 0.5);
// { x: 960, y: 540 } for 1920x1080 screenReturns: Coordinates ({ x: number, y: number })
Convert pixel coordinates to normalized (0-1).
const normalized = jsPsych.extensions.tobii.pixelsToNormalized(960, 540);
// { x: 0.5, y: 0.5 }Returns: Coordinates
Convert window pixel coordinates to container-relative coordinates.
const containerCoords = jsPsych.extensions.tobii.windowToContainer(x, y);Parameters:
x(number) - X coordinate in window pixelsy(number) - Y coordinate in window pixelscontainer(HTMLElement, optional) - Defaults to jsPsych display element
Returns: Coordinates
Get window dimensions.
const screen = jsPsych.extensions.tobii.getScreenDimensions();
// { width: 1920, height: 1080 }Returns: ScreenDimensions ({ width: number, height: number })
Get container element dimensions.
const dims = jsPsych.extensions.tobii.getContainerDimensions();Returns: ScreenDimensions
Check if window coordinates fall within a container.
const inside = jsPsych.extensions.tobii.isWithinContainer(x, y);Returns: boolean
Calculate Euclidean distance between two points.
const dist = jsPsych.extensions.tobii.calculateDistance({ x: 0, y: 0 }, { x: 1, y: 1 });Returns: number
Export data to a CSV file (triggers browser download).
const allData = jsPsych.data.get().values();
jsPsych.extensions.tobii.exportToCSV(allData, 'experiment_data.csv');Parameters:
data(any[]) - Data array to exportfilename(string) - Output filename
Export data to a JSON file (triggers browser download).
const allData = jsPsych.data.get().values();
jsPsych.extensions.tobii.exportToJSON(allData, 'experiment_data.json');Parameters:
data(any[]) - Data array to exportfilename(string) - Output filename
Get time synchronization offset between browser and server.
const offset = jsPsych.extensions.tobii.getTimeOffset();Returns: number (milliseconds)
Check if time is synchronized with server.
const synced = jsPsych.extensions.tobii.isTimeSynced();Returns: boolean
Convert a performance.now() timestamp to Tobii device clock time. Requires device time sync to be established.
const deviceTime = jsPsych.extensions.tobii.toDeviceTime(performance.now());Returns: number
Convert a Tobii device clock timestamp to performance.now() domain. Requires device time sync to be established.
const localTime = jsPsych.extensions.tobii.toLocalTime(deviceTimestamp);Returns: number
Check if the browser-to-device time sync chain is established.
const synced = jsPsych.extensions.tobii.isDeviceTimeSynced();Returns: boolean
Get full device time synchronization status with all offsets and diagnostics.
const status = jsPsych.extensions.tobii.getTimeSyncStatus();Returns: DeviceTimeSyncStatus with fields:
synced(boolean) - Whether device time sync is availableoffsetAB(number) - Browser to server offset in msoffsetBC(number | null) - Server to device clock offset in msoffsetAC(number | null) - Browser to device clock offset in msbcSampleCount(number) - Number of B-C offset samples usedbcStdDev(number | null) - Standard deviation of B-C samples in msbcMin(number | null) - Minimum B-C offset in msbcMax(number | null) - Maximum B-C offset in ms
Validate timestamp alignment across a set of gaze samples.
const result = jsPsych.extensions.tobii.validateTimestampAlignment(gazeSamples);
// { sampleCount: 50, meanResidual: 2.3, stdDev: 0.8, min: 0.5, max: 4.1 }Parameters:
samples(GazeData[]) - Gaze samples withclientTimestampset
Returns: TimestampAlignmentResult | null
Update extension configuration.
jsPsych.extensions.tobii.setConfig({
connection: { reconnectAttempts: 10 },
});Parameters:
config(Partial) - Configuration options:connection-{ url?, autoConnect?, reconnectAttempts?, reconnectDelay? }
Get current configuration.
const config = jsPsych.extensions.tobii.getConfig();Returns: InitializeParameters
Symptoms: jspsych-tobii-server command fails, error about missing eye tracker.
Solutions:
- Check eye tracker connection (USB or network)
- Verify Tobii Eye Tracker Manager recognizes the tracker
- Update the Python package:
pip install --upgrade jspsych-tobii - Try the mock adapter for testing:
jspsych-tobii-server --adapter mock
Symptoms: "WebSocket connection failed" error in browser console.
Solutions:
- Verify server is running:
netstat -an | grep 8080 - Ensure URL matches:
ws://localhost:8080 - Check firewall settings for port 8080
- Serve your experiment from a local web server (avoid
file://protocol)
Symptoms: Gaze data stops appearing mid-experiment.
Solutions:
- Use a high-quality USB cable, connect directly (not through a hub)
- Adjust reconnection settings:
params: { connection: { url: 'ws://localhost:8080', reconnectAttempts: 10, reconnectDelay: 2000, }, }
- Monitor server logs:
jspsych-tobii-server --log-level debug
Solutions:
- Use position guide first to optimize participant position
- Increase
point_duration(e.g., 750ms) - Try click mode:
calibration_mode: 'click' - Check environmental factors: reduce ambient lighting, remove reflective surfaces, clean tracker lens
Solutions:
- Large monitors may exceed tracking range at edges
- Adjust participant distance using the position guide
- Use custom points to avoid extreme edges:
custom_points: [ { x: 0.15, y: 0.15 }, { x: 0.5, y: 0.15 }, { x: 0.85, y: 0.15 }, // ... ]
Solutions:
- Verify extension is added to trials:
extensions: [{ type: jsPsychExtensionTobii }] // Don't forget this!
- Check tracking state:
jsPsych.extensions.tobii.isTracking() - Ensure calibration completed successfully first
Solutions:
- Recalibrate (calibration may have degraded or participant moved)
- Check coordinate system: gaze data is normalized (0-1) by default
- Convert to pixels:
jsPsych.extensions.tobii.normalizedToPixels(gaze.x, gaze.y)
Solutions:
- Verify time synchronization:
jsPsych.extensions.tobii.isTimeSynced() - Use
clientTimestampfor aligning with other browser events - Use
validateTimestampAlignment()to check sync quality
Solutions:
- Use Chrome/Chromium for best WebSocket performance
- Clear old data periodically:
jsPsych.extensions.tobii.clearGazeData()
Solutions:
- Lower sampling rate (if supported by your tracker)
- Increase
update_intervalon the position guide plugin (e.g., 200ms)
- Chrome/Chromium: Best compatibility and performance (recommended)
- Firefox: May have WebSocket performance issues; use latest version
- Safari: Enable WebSocket support in preferences; prefer Chrome for experiments
| Error | Cause | Solution |
|---|---|---|
| "Tobii extension not initialized" | Plugin tried to use extension before setup | Add extension to initJsPsych({ extensions: [...] }) |
| "Not connected to server" | Using features before WebSocket connects | Call await jsPsych.extensions.tobii.connect() or use autoConnect: true |
| "Invalid calibration point" | Coordinates outside 0-1 range | Ensure all coordinates are normalized (0-1) |
| "Request timeout" | Server didn't respond in time | Check server is running, check network |
| Package | Description |
|---|---|
| @jspsych/extension-tobii | Core extension |
| @jspsych/plugin-tobii-calibration | Calibration plugin |
| @jspsych/plugin-tobii-validation | Validation plugin |
| @jspsych/plugin-tobii-user-position | User position guide plugin |
| jspsych-tobii | Python WebSocket server |
This is a monorepo managed with npm workspaces.
npm install # Install dependencies
npm run build # Build all packages
npm test # Run all JS tests
npm run lint # ESLint
npm run format # Prettier
cd python && pytest # Run Python testsMIT