For this summer school, we will need to install a few important programs on our laptops:
NodeJS: to create Javascript servers on our computers and in the cloud
Miniforge: to create Python environments
MongoDb Compass to access and visualize our databases
Git and Github CLI: to facilitate collaborative work!
VS Code: the perfect IDE for this summer school
To run a web experiment, we need a simple web server. One of the simplest options is to use Nodejs to develop this server with JavaScript. It is available on all platforms.
Download it here: https://nodejs.org/en
Take version 22 LTS
If you already have a virtual environment manager for Python, you may skip this step.
Otherwise, please follow the instructions on the Miniforge repository.
Just download and install from this site https://www.mongodb.com/products/tools/compass
If you don’t already have it, download these tools from the following websites. You may want to open a terminal and type git --version
to make sure you don’t have Git already.
If you don’t already have VS Code (or a similar editor you are familiar with), download and install it from this website
In any terminal, the commands dir
or ls
lists the content of the current directory, and the command cd
allows you to navigate in your filesystem.
cd ..
to go back in the parent directory
cd example_subfolder
to go into a subfolder of your current directory
cd example_subfolder/example_subsubfolder
to go in a nested subsubfolder
cd C:/root_folder/
or cd /root_folder
to use an “absolute” path
Note that the Tab key is quite useful to autocomplete when navigating
mkdir your_new_dir
allows you to create a new directory
Go to the exercise repository: https://github.com/robustcircuit/neurograd-simple-experiment
Follow the instructions
<!DOCTYPE html>
<html>
<head>
<script src="js/jspsych/dist/jspsych.js"></script>
<script src="js/jspsych/dist/plugin-preload.js"></script>
<script src="js/jspsych/dist/plugin-image-button-response.js"></script>
<script src="js/jspsych/dist/plugin-image-keyboard-response.js"></script>
<script src="js/jspsych/dist/plugin-html-button-response.js"></script>
<script src="js/jspsych/dist/plugin-html-keyboard-response.js"></script>
<link rel="stylesheet" href="css/jspsych.css" />
<link href="img/favicon.ico" rel="icon" />
</head>
<body></body>
<script>
// retrieve information
// name the current session
var basename = "expNOWdata";
// get timestamp of initial loading of the script
var currDate = new Date();
//////////////////////////////////////////////////////
// Define experiment parameters
//////////////////////////////////////////////////////
// We store experiment parameters in a dedicated object
var expdef = {};
// define timing parameters in milliseconds (jsPsych default unit)
expdef["choiceMaxDuration"] = 3000;
expdef["feedbackDuration"] = 500;
expdef["fixationDuration"] = 500;
// define default repetition of images within blocks
expdef["defaultRepetition"] = 1;
// define the message displayed at the beginning of the experiment
expdef["experimentIntroMsg"] =
"<p>In this short experiment, you will have to learn to associate " +
"specific images with specific response buttons, by trial and error.<br>" +
"The experiment is divived in blocks that correspond to different categories of images.</p>";
expdef["experimentIntroButton"] = ["Click here to start the experiment"];
// define the message displayed at the end of the experiment
expdef["experimentEndMsg"] = "<p>Thanks for participating</p>";
expdef["experimentEndButton"] = ["Click here to save the data"];
// define the message displayed at the beginning of each block
expdef["blockIntroMsg"] =
"You are going to start a new block with images of ";
expdef["blockIntroButton"] = ["Click here to start the block"];
// define the message displayed below images
expdef["choiceMsg"] =
"<p><b>Click</b> on the correct response button for this image</p>";
// define images and messages used for feedback
expdef["feedbackMsg"] = [
"<p>Missed! <br>Respond faster next time</p>",
"<p>Incorrect</p>",
"<p>Correct</p>",
];
// define images paths for feedback
expdef["feedbackImg"] = [
"img/missedFb.png",
"img/thumbsDown.png",
"img/thumbsUp.png",
];
// define the image names that will be used in the experiment
var imageNames = {};
imageNames["fruits"] = [
"banana",
"blueberry",
"grapefruit",
"kiwi",
"pineapple",
"raspberry",
];
imageNames["vegetables"] = [
"beet",
"brussels_sprouts",
"carrot",
"eggplant",
"lettuce",
"pumpkin",
];
//////////////////////////////////////////////////////
// Init jsPsych
//////////////////////////////////////////////////////
var jsPsych = initJsPsych({
on_finish: function (done) {
document.body.style.cursor = "pointer";
var interaction_data = jsPsych.data.getInteractionData();
interaction_data.trial_tag = "interaction";
jsPsych.data.get().push(interaction_data);
jsPsych.data.get().push({
completedTask: "RLWM_expNOW",
basename: basename,
starting_time: currDate,
});
jsPsych.data.get().localSave("csv", basename + ".csv");
display_data();
jsPsych.data.get()
$.ajax({
type: "POST",
url: "/save-file",
data: jsPsych.data.get().json(),
contentType: "application/json",
});
},
});
//////////////////////////////////////////////////////
// Generate trial list
//////////////////////////////////////////////////////
// block definition
var includedCategories = ["vegetables", "fruits"];
var setSizes = [1, 2];
var mapSR = [[0], [0, 1]];
var blockTrials = [expdef.defaultRepetition, expdef.defaultRepetition];
// prepare block shuffling
var blockIdx = [];
for (var b = 0; b <= includedCategories.length - 1; b++) {
blockIdx.push(b);
}
blockIdx = this.jsPsych.randomization.shuffle(blockIdx);
// loop over blocks, images and repetitions
trialStructure = [];
for (var b = 0; b <= includedCategories.length - 1; b++) {
// shuffle images (with respect to response mappings mapSR)
blockImgs = this.jsPsych.randomization.shuffle(
imageNames[includedCategories[blockIdx[b]]]
);
blockImgs = blockImgs.slice(0, setSizes[blockIdx[b]]);
subStructure = [];
// loop over images in the block
for (var i = 0; i <= blockImgs.length - 1; i++) {
// loop over repetition within blocks
for (var r = 0; r <= blockTrials[blockIdx[b]] - 1; r++) {
var trialspecs = {
imgCategory: includedCategories[blockIdx[b]],
imgId: blockImgs[i],
repetitionCount: r,
correctResponse: mapSR[blockIdx[b]][i],
block: b,
setSize: setSizes[blockIdx[b]],
};
subStructure.push(trialspecs);
}
}
// randomize trials within a block
subStructure = this.jsPsych.randomization.shuffle(subStructure);
trialStructure.push(subStructure);
}
//console.log(trialStructure)
//////////////////////////////////////////////////////
// Generate trial objects
//////////////////////////////////////////////////////
var experimentIntro = {
type: jsPsychHtmlButtonResponse,
stimulus: function () {
return expdef.experimentIntroMsg;
},
choices: expdef.experimentIntroButton,
response_ends_trial: true,
};
var blockIntro = {
type: jsPsychHtmlButtonResponse,
stimulus: function () {
return expdef.blockIntroMsg + includedCategories[blockIdx[blockNum]];
},
choices: expdef.blockIntroButton,
response_ends_trial: true,
};
var fixationTrial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: "<p style='font-size: 3rem;'>+</p>",
trial_duration: expdef["fixationDuration"],
response_ends_trial: false,
};
var choiceTrial = {
type: jsPsychImageButtonResponse,
stimulus: function () {
var imgPath =
"./img/" +
trialStructure[blockNum][trialNum].imgCategory +
"/" +
trialStructure[blockNum][trialNum].imgId +
".jpg";
return imgPath;
},
trial_duration: expdef["choiceMaxDuration"],
choices: ["Left", "Middle", "Right"],
prompt: expdef["choiceMsg"],
stimulus_width: 400,
maintain_aspect_ratio: true,
response_ends_trial: true,
on_start: function () {},
on_finish: function (data) {
if (data.response === null) {
data.accuracy = -1;
} else if (
data.response == trialStructure[blockNum][trialNum].correctResponse
) {
data.accuracy = 1;
} else {
data.accuracy = 0;
}
},
};
var feedbackTrial = {
type: jsPsychImageKeyboardResponse,
stimulus: function () {
var lastCorrect = jsPsych.data.get().last(1).values()[0].accuracy;
console.log(expdef["feedbackImg"]);
return expdef["feedbackImg"][lastCorrect + 1];
},
prompt: function () {
var lastCorrect = jsPsych.data.get().last(1).values()[0].accuracy;
return expdef["feedbackMsg"][lastCorrect + 1];
},
stimulus_width: 150,
maintain_aspect_ratio: true,
trial_duration: expdef["feedbackDuration"],
response_ends_trial: false,
};
var experimentEnd = {
type: jsPsychHtmlButtonResponse,
stimulus: function () {
return expdef.experimentEndMsg;
},
choices: expdef.experimentEndButton,
response_ends_trial: true,
};
// function to display data at the end of the experiment
var display_data = function () {
// set an HTML div
const display_element = this.jsPsych.getDisplayElement();
display_element.innerHTML = '<pre id="jspsych-data-display"></pre>';
var data = jsPsych.data.get();
var correctN = data.filter({ accuracy: 1 }).select("accuracy").count();
var overallN = data.select("accuracy").count();
var correctRT = data.filter({ accuracy: 1 }).select("rt").values;
var incorrectRT = data.filter({ accuracy: 0 }).select("rt").values;
var htmlStr =
"<p>You gave " +
((100 * correctN) / overallN).toFixed(1) +
"% of correct responses. </p><br><br>";
htmlStr +=
"<p>On average, it took you " +
(correctRT / 1000).toFixed(3) +
"s to give a correct response and ";
htmlStr +=
(incorrectRT / 1000).toFixed(3) +
"s to give an incorrect response.</p>";
document.getElementById("jspsych-data-display").innerHTML = htmlStr;
};
//////////////////////////////////////////////////////
// Create nest timelines
//////////////////////////////////////////////////////
// counters
var trialGlobNum = 0;
var blockNum = 0;
var trialNum = 0;
// trial timeline, loops until end of block
var trial_timeline = {
timeline: [fixationTrial, choiceTrial, feedbackTrial],
on_timeline_start: function () {
tBlock = 0;
},
loop_function: function () {
console.log("block" + blockNum + " and trial" + trialNum);
if (trialNum < trialStructure[blockNum].length - 1) {
trialNum++;
return true;
} else {
trialNum = 0;
blockNum++;
return false;
}
},
};
// block timeline, loops until the end of the experiment
var block_timeline = {
timeline: [blockIntro, trial_timeline],
loop_function: function () {
if (blockNum < trialStructure.length) {
return true;
} else {
return false;
}
},
};
// main timeline, will only run once
var main_timeline = [experimentIntro, block_timeline, experimentEnd];
jsPsych.run(main_timeline);
</script>
</html>
// get packages
var fs = require("fs");
var path = require("path");
var express = require("express");
var cors = require("cors");
require("dotenv").config();
// --- INSTANTIATE THE APP
var app = express();
// manage cors policy
app.use(cors());
app.use(express.static(__dirname + "/public/"));
// set views
app.set("views", path.join(__dirname, "/public/"));
// set routes
app.get("/expNOW", function (request, response) {
response.render("experiment.html");
});
// set view engigne
app.engine("html", require("ejs").renderFile);
app.set("view engine", "html");
// START THE SERVER
app.listen(3000, function () {
console.log(
"Server running. To see the experiment that it is serving, visit the following address:"
);
console.log("http://localhost:%d/expNOW", 3000);
});
Open Visual Code or the IDE of your choice and type
Then, open http://localhost:3000/expNOW in your browser.
You should see the experiment running (if you’ve followed the installation steps).
To stop and restart the server, just press Ctrl+C and type again node app
.
The server should be restarted each time you make a change to the server code.
To see the console, you must toggle the developer tab of your browser (most often using key F12 or Fn+F12).
Developer tabs of Firefox
Select console
and type expdef
+ Enter to see our experiment definitions object.
You can also add console.log(something)
statements anywhere in your scripts to print dynamically the value (and structure) of Javacript variables, objects, etc.
All developer tabs have an “inspector” tool that allows you to check and understand the static structure of any webpage, as well as the name of the elements you can manipulate. For example, the first screen of the experiment contains the following code:
<body style="margin: 0px; height: 100%; width: 100%;" tabindex="0" class=" jspsych-display-element">
<div class="jspsych-content-wrapper">
<div id="jspsych-content" class="jspsych-content">
<div id="jspsych-html-button-response-stimulus">
<p>In this short experiment, you will have to learn to associate specific images with specific response buttons, by trial and error.<br>The experiment is divived in blocks that correspond to different categories of images.</p>
</div>
<div id="jspsych-html-button-response-btngroup">
<div class="jspsych-html-button-response-button" style="display: inline-block; margin:0px 8px" id="jspsych-html-button-response-button-0" data-choice="0">
<button class="jspsych-btn">Click here to start the experiment</button>
</div>
</div>
</div>
</div>
</body>
The id
and class
attributes can be used to access each of these objets (divs, buttons, paragraph) and change their appeareance or behavior.
In the previous slide, we show a piece of HTML code that appears in the Inspector. Can you find this code as is
in the experiment.html file? Why?
Also, have a look at the Debugger tab. It looks a bit like our local directory “server”. But it is not complete. Do you know why? If not, have a look at the top of your experiment.html file :).
Web apps involve a huge amount of concepts, tools, objects, librairies, etc. You don’t need to understand everything in depth since many frameworks can do the hard work for you (a bit like jsPsych does).
Don’t start from scratch
Run very frequently your code as you modify it
Make sure you’re reading the adequate documentation (version++)
Ask questions: what example code means, what bugs mean, what are the most likely causes and solutions
ChatGPT is pretty good at generating or explaining bits of JS code from natural language queries.
Experiments can fail at any stage, but they tend to fail much more often at the beginning or at the end. So, always keep them as short as possible when we still need to debug them.
So, the first thing we will do is to shorten ours even more. Try the following changes in `experiment.html
//expdef["feedbackDuration"] = 1000;
//expdef["fixationDuration"] = 1000;
expdef["feedbackDuration"] = 500;
expdef["fixationDuration"] = 250;
//var setSizes = [3, 6];
//var mapSR = [[0, 1, 2], [0, 1, 2, 0, 1, 2]];
var setSizes = [1, 2];
var mapSR = [[0], [0, 1]];
This should reduce quite drastically the duration without removing any key ingredients.
URL parameters are passed in the URL using the symbol “?” and several parameters can be chained using the symbol “&”.
// just copy paste this somewhere at the top
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
var SUBJECT = 'unknown'
if (urlParams.has('SUBJECT')) {SUBJECT = urlParams.get('SUBJECT'); }
var SUFFIX = 'unknown'
if (urlParams.has('SUFFIX')) {SUFFIX = '_' + urlParams.get('SUFFIX');}
Note
This approach will allow to share specific URLs to our participants and therefore keep track of who did what.
Now, we should use a web address looking like: localhost:3000/expNOW?SUBJECT=someone&SUFFIX=v1
We also need to modify the following line if we want to use this information to identify the output data!
Now, try to use the SUBJECT
variable to give a personalized “Hello” to your participants!
Note
Our first message to participants is defined by expdef["experimentIntroMsg"]
To do so, we need to use a plugin that jsPsych does not import by default, so we need to import it manually in our experiment, in the <head> section.
Find the actual file and have a look at the list of plugins provided by jsPsych. They might be useful for other experiments of yours!
Then, we just need to add the following jsPsych objects to our script, after the initialization of jsPscych.
By default, jsPsych comes with very sober Cascading Style Sheets (CSS), but CSS can be easily updated to make things prettier. So, if we add the custom_jspsych.css stylesheet to after the default jspsych.css one, the experiment is already looking different.
<link rel="stylesheet" href="css/jspsych.css">
<link rel="stylesheet" href="css/custom_jspsych.css">
This is because custom_jspsych.css
contains new styling information. Try to make additional changes to the visual appearance of the experiment by modifying or extending custom_jspsych.css
.
/*to change all font colors of a given class */
.whateverclass {
color: blue;
}
/*to change font weight of all several headings of a given class at once */
.whateverclass h2,h3,h4 {
font-weight: 700;
}
/*to change the background of elements with a given ID */
#whateverID {
background-color: rgb(2500, 0, 0);
}
Tip
Right click on any part of a web pages and click inspect to see its class or id. Do not forget semi-colons after each attribute!
Open this page and adapt the demo code provided to add a survey.
Use the CDN-hosted Javascript file or the Javascript file that is present in js/jspsych/dist/survey-likert
Create just 2 or 3 questions that make sense in the context of this experiment
Study the code to find out how you can do this. Don’t hesitate to make questions if you are stuck.
If you have access to Internet, you can be creative! Otherwise just use the third folder provided in img
.
Just a hint to get you started, you might have to add code like this somewhere:
Of course, there will be a few other changes to implement to make it work.
Add the following piece of code where it looks reasonable
var display_data = function () {
const display_element = this.jsPsych.getDisplayElement(); // find main div of jsPsych
display_element.innerHTML = '<pre id="jspsych-data-display"></pre>'; // set a new HTML div
var data=jsPsych.data.get(); // get all jsPsych data
// add the data to our new div
document.getElementById("jspsych-data-display").textContent = JSON.stringify(data);
};
Then, you’ll need to update the on_finish routine of jsPsych to execut this function
Remove line document.getElementById("jspsych-data-display").textContent = JSON.stringify(data);
and add instead:
var correctN = data.filter({accuracy: 1}).select('accuracy').count();
var overallN = data.select('accuracy').count();
var correctRT=data.filter({accuracy: 1}).select('rt').mean();
var incorrectRT=data.filter({accuracy: 0}).select('rt').mean();
var htmlStr = '<p>You gave ' + (100*correctN/overallN).toFixed(1) + '% of correct responses. <br><br>'
htmlStr += '<p>On average, it took you ' + (correctRT/1000).toFixed(3) + 's to give a correct response and '
htmlStr += (incorrectRT/1000).toFixed(3) + 's to give an incorrect response. '
document.getElementById("jspsych-data-display").textContent = htmlStr;
Log in your Github account.
Go to “Repositories”
Click “New”
Give your repo a name, leave all other fields untouched.
Copy-paste the URL (e.g. https://github.com/username/yournewrepo.git)
Coming back to the terminal, make sure you are in the root of the repo (in neurograd-simple-experiment
) and type: git remote set-url origin https://github.com/username/yournewrepo.git
to associate your new Github repository with the local repo.
Now, let’s perform the most standard combo of git commands ever: git add .
git commit -m "first commit message"
git push
Refresh the URL of your new repo (e.g. https://github.com/username/yournewrepo.git) to see the results.
If you see the content of your local repo, we’re all set!
Now, we can work with Render.
Go to https://dashboard.render.com/register
Under “Create an account”, click on Github to create your account using your Github identity.
Then, once arrived on the Dashboard, click “+ Add New” (black button on the top right) and select “Web service”
Click on the tab “Public Git Repository” and copy paste the URL of your own repo.
On the screen screen, find the line “Build Command” and replace “yarn” by “npm –prefix server install”.
Just below, at the “Start Command” line, write “node server/app.js”
Once you see the lines
==> ///////////////////////////////////////////////////////////
==> Available at your primary URL https://xxxx-xxx.onrender.com
==> ///////////////////////////////////////////////////////////
You should be able to perform the experiment at https://xxxx-xxx.onrender.com/expNow
If you do the experiment, you’ll see that the data will be downloaded on the computer of the pilot, rather than yours.
This is not really convenient… Sure, you could ask participants to send you their logfile by email, but that not the idea of online testing.
Add this to the head section of your experiment.html file
Add this at the end of the on_finish function of initjsPsych
Now add the following lines to your server app.js
, before the app.listen()
call.
var bodyparser = require("body-parser");
app.use(bodyparser.json({ limit: "50mb" }));
app.post("/save-file", function (request, response) {
var datestr = new Date();
datestr = String(datestr.toISOString()).replace(/:|\s+|_/g, '')
var filename = String(request.body[request.body.length - 1].basename + "_" + datestr + ".json");
// If you were serving the experiment from a "full" server, you could run the line below:
//fs.writeFile(path.join(__dirname, "logdata/" + filename), JSON.stringify(request.body), (err) => {if (err) throw err; response.end(); });
// However, if you are serving it from a web service (e.g. render.com) it won't work (no filesystem, only a running app)
console.log(`A logfile has been received: ${filename}\nIf we are in the cloud, we should send this immediately to a database..`)
});
To connect with a MongoDB database, we need to set a MONGODB_URI variable. But with this variable anyone will be able to read and write from our database, so we should make sure it is never published online! That why it was shared with you by email.
To store it, we’ll create a .env file in the “server” subfolder, we’ll open it and we’ll paste the connection string received by email after MONGODB_URI=
Now, we need to install a NodeJS module to deal with MongoDB databases cd server
npm install mongoose
Add this towards the top of you app.js file
var mongoose = require("mongoose");
const yourName="Romain"
const dbSchema = new mongoose.Schema({}, {
strict: false,
collection: yourName // bind schema to specific collection
});
const dbModel = mongoose.model(yourName, dbSchema);
mongoose.connect(process.env.MONGODB_URI);
var db = mongoose.connection;
db.on("error", console.error.bind(console, "connection error"));
db.once("open", function callback() {
console.log("database opened");
});
Then, modify the lines dealing with save-file request
app.post("/save-file", function (request, response) {
var datestr = new Date();
datestr = String(datestr.toISOString()).replace(/:|\s+|_/g, '')
var filename = String(request.body[request.body.length - 1].basename + "_" + datestr + ".json");
dbModel.create(JSON.stringify(request.body));
response.status(200).send({ message: 'success' });
});
Update your Gihub repo (add, commit, push).
Wait a little and check Render has successfully built the new version of the repo.
Do the experiment until the end.
Copy-paste the MONGODB_URI connection string in MongoDB Compass to explore the database.
Log into Prolific with the credentials provided by email. Explore the interface (Projects=>Study).
What is THE key change we should make to our experiment.html to make it work with Prolific?
We will use an experiment found on the web, but you can try with your own pick.
Open a terminal and cd
inside now-workshop repository folder.
Type git clone https://github.com/jspsych/experiment-demos
Choose one of the 4 demo experiments and adapt it to serve it from experiment2.html
Overall steps to follows:
Copy-paste the content of a demo folder into your public
subfolder.
Create a route towards the new .html file in our server script.
Adapt the on_finish
method contained in our jsPsych.init
section to send the data to your server at the end of the experiment! You will need to: (i) make decision about whether you want to use URL parameters or not (and implement them or define the basename
variable in another way), (ii) push the basename attribute in jsPsych.data in the same way as it is done in experiment.html
If you want to serve different experiments at different addresses, you will need to modify a bit the server (app.js
).
ExpressJS makes it very simple to serve a new page. You just need to duplicate and modify the following lines to serve a new HTML file (e.g. visual_search.html
) at a new address (e.g. at /visualsearch
).
Each app.get()
defines a “route” linking a URL subaddress (such as localhost:3000/expNOW) to a specific HTML file present in our views folder (such as experiment.html
).
Use the documentation of the preload plugin to find a way to (manually) preload all images of the experiment.
Tip
You can easily construct a list of the paths of all the images displayed in the experiment in the constructor loop that runs over blocks and images.
The JSON format is one of the most common and useful file formats for web programming, modern databases (NoSQL) and beyond. Right now, the data of this tutorial is saved in a very inefficient .csv format. Try to find the line that will allow you to save a .json file instead!
The resulting file may look like this. Can you see the key difference with a standard .csv format?
[{"trial_type":"call-function","trial_index":0,"time_elapsed":1,"internal_node_id":"0.0-0.0","task_id":"GONOGO","subject_id":"p001","study_id":"1","session_id":"2","date":"20230612_0915"},{"success":true,"timeout":false,"failed_images":[],"failed_audio":[],"failed_video":[],"trial_type":"preload","trial_index":1,"time_elapsed":34,"internal_node_id":"0.0-1.0","task_id":"GONOGO","subject_id":"p001","study_id":"1","session_id":"2","date":"20230612_0915"},{"success":true,"trial_type":"fullscreen","trial_index":2,"time_elapsed":2122,"internal_node_id":"0.0-2.0","task_id":"GONOGO","subject_id":"p001","study_id":"1","session_id":"2","date":"20230612_0915"}]
Go beyond tutorials to implement professional experiments can be tough.
Data protection requires a deeper understanding of server-side operations and encryption methods
Large-scale data curation requires databases that require specific code
Demanding experiments w.r.t timing accuracy require specific tools/code
Unusual or complex experiments require to program custom JS plugins from scratch
Scalability and usability of experiments across devices require responsive HTML or SVG
Reusability of the same code base in the lab for fMRI, EEG, TMS, etc., as well as mobile or desktop apps, is feasible but it requires advanced JS skills.
Questions?