Skip to content

P300 Tic-Tac-Toe

A complete P300 brain-computer interface that records event-related potentials from the Knight Board, learns the difference between attended and unattended visual flashes, and uses that difference to choose moves in tic-tac-toe. The pipeline uses BrainFlow for synchronized EEG and stimulus markers, Pygame for the visual grid, and linear discriminant analysis (LDA) for classification.

Full source: github.com/NeuroPawn/p300

The P300 is a positive event-related potential (ERP) that often appears a few hundred milliseconds after a person detects a meaningful or infrequent event. In an oddball paradigm, many ordinary stimuli are mixed with occasional target stimuli. When the target appears and the user is actively attending to it, the target flash tends to produce a stronger positive response than the non-target flashes.

This project presents a 3 × 3 grid. The user keeps their attention on one cell while cells flash one at a time. A flash on the attended cell is a target; a flash anywhere else is a non-target. The resulting waveforms overlap, so one flash is not always enough. During live selection, each available cell is flashed three times and the classifier’s target probabilities are averaged. The cell with the highest average becomes the user’s selection.

The response does not always peak at exactly 300 ms. The repository preserves the full response through 800 ms and explicitly measures the 350–650 ms region, where a later P300 is still visible.

P300 responses are commonly strongest over central-parietal and parieto-occipital regions. The montage used to develop this repository is:

ChannelPositionRegion
1P3Left parietal
2P1Left medial parietal
3PzMidline parietal
4P2Right medial parietal
5P4Right parietal
6PO3Left parieto-occipital
7POzMidline parieto-occipital
8PO4Right parieto-occipital

Keep the same channel order for collection, testing, and live use. The saved model records the expected channel count, and the online scripts stop if the live board does not match it. Follow the Getting Started guide for reference, RLD, headset, and signal-quality setup before collecting P300 data.

collection.py, online_test.py, and game.py all initialize the board the same way. They start the stream, turn on all eight channels at gain 12, and add each channel to the RLD loop:

Knight Board channel setup
board.prepare_session()
board.start_stream(450000)
time.sleep(3.0)
for channel in range(1, 9):
board.config_board(f"chon_{channel}_12")
time.sleep(1.0)
board.config_board(f"rldadd_{channel}")
time.sleep(1.0)

The pauses are intentional: they give the firmware time to apply every command. Allow the setup to finish before beginning a trial. See the Command Set for the channel and RLD commands.

The collection settings live at the top of collection.py:

SettingRepository default
Grid3 × 3 cells
Trials15
Flashes per trial20
Target flashes per trial5
Non-target flashes per trial15
Flash duration100 ms
Sampling rate125 Hz

At the start of each trial, the program chooses a target cell and tells the user which coordinates to watch. Press Space, keep your gaze and attention on that cell, and avoid blinking or moving while the flashes run. The trial then randomizes exactly five target and fifteen non-target events.

Each flash follows the same sequence:

  1. Draw the selected cell in white.
  2. Insert a unique value into BrainFlow’s marker channel at stimulus onset.
  3. Keep the cell white for 100 ms.
  4. Wait for the complete post-stimulus response to arrive.
  5. Find that marker in the board buffer and extract its EEG epoch.

The target label is 1; the non-target label is 0. Correct attention matters: if you look at a different cell from the instructed target, the recorded EEG and the assigned label disagree, weakening the model.

P300 classification depends on knowing exactly when each flash occurred. The code inserts a unique marker immediately after Pygame displays the flash, then retrieves a non-destructive three-second snapshot with get_current_board_data(). It locates the marker and cuts a 1.05-second epoch:

The −200 to 0 ms baseline contains 25 samples. The post-stimulus ERP window runs from 0 to +850 ms and contains 106 samples at 125 Hz.

Each stored event therefore has shape 8 channels × 131 samples. The 200 ms before the marker provides a local baseline; the 850 ms after it contains the early visual response, P300, and later tail.

Filtering is applied to the larger three-second buffer before the short epoch is extracted. This gives the filter enough surrounding signal and avoids strong edge artifacts at the start and end of every ERP.

Collection, offline evaluation, online selection, and the game use the same pre-processing steps:

  1. Linear detrending removes slow drift from each channel.
  2. A 0.5–30 Hz fourth-order Butterworth band-pass preserves the slow ERP while reducing movement and high-frequency muscle noise.
  3. The epoch is cut from −200 to +850 ms around the marker.
  4. Baseline correction subtracts each channel’s mean from −200 to 0 ms.

There is no separate 50/60 Hz notch because the 30 Hz upper cutoff already removes power-line frequencies. The code also deliberately avoids per-epoch z-scoring so the ERP’s amplitude difference is preserved.

training.py turns each channel into six features. Five are mean amplitudes in successive windows after the flash:

WindowWhat it captures
0–200 msEarly visual response / N1–P2
200–350 msN2 and early P300 activity
350–500 msMain P300 rise
500–650 msLate P300 activity
650–800 msLater ERP tail

The sixth feature is a contrast:

CP300=xˉ350650msxˉ0200msC_{\mathrm{P300}} = \bar{x}_{350\text{–}650\,\mathrm{ms}} - \bar{x}_{0\text{–}200\,\mathrm{ms}}

With eight channels, that produces 48 features per flash. The pipeline then:

  1. Makes a stratified 75/25 training and holdout split.
  2. Fits StandardScaler on the training features.
  3. Trains LinearDiscriminantAnalysis with equal class priors, preventing the more common non-target class from dominating solely because it has more examples.
  4. Reports holdout accuracy, a confusion matrix, and a classification report.
  5. Runs stratified five-fold cross-validation on the full dataset.
  6. Saves the classifier, scaler, feature method, sampling rate, epoch shape, feature count, and validation scores together in one .pkl model.

LDA is a good fit for this small ERP dataset: it learns a linear combination of the spatial and temporal features that separates target from non-target flashes, without requiring a large neural-network training set.

The project uses Python 3.11. Clone it and create the supplied Conda environment:

Terminal window
git clone https://github.com/NeuroPawn/p300.git
cd p300
conda env create -f environment.yml
conda activate neuropawn

Alternatively, create the environment and install the four dependencies directly:

Terminal window
conda create -n neuropawn python=3.11
conda activate neuropawn
pip install -r requirements.txt

The requirements are BrainFlow, NumPy, Pygame, and scikit-learn.

The repository keeps configuration constants at the top of each script. Check these paths before running each stage:

FileValues to set
collection.pySERIAL_PORT, OUTPUT_NPZ, OUTPUT_LOG
training.pyINPUT_DATA, OUTPUT_MODEL
testing.pyMODEL_FILE, TEST_DATA_FILE, threshold
online_test.pySERIAL_PORT, MODEL_FILE
game.pySERIAL_PORT, MODEL_FILE

On Windows the serial port is usually similar to COM3. On Linux and macOS it will be a /dev/tty... device. Use the same trained model path in testing.py, online_test.py, and game.py.

Set OUTPUT_NPZ to a file under data/training/, connect the board, put on the headset, and run:

Terminal window
python collection.py

Follow the target coordinate shown before every trial. Press Space only when you are settled and ready to maintain attention. The script saves the epochs and labels to .npz, plus detailed timing and marker metadata to a JSON log.

For multiple sessions, update other/combine.py with the files you want to merge and use its combined output as training.py → INPUT_DATA. More clean, independent trials are generally more useful than one long session collected after the user is fatigued.

Set other/view.py → NPZ_FILE to the recording and run:

Terminal window
python other/view.py

The plots compare target and non-target averages and highlight the 300–600 ms region. Look for a repeatable separation over the parietal channels. If both averages are noisy and nearly identical, improve electrode contact or recollect before training.

Point training.py → INPUT_DATA at the training recording or combined dataset, choose OUTPUT_MODEL, then run:

Terminal window
python training.py

Review both target and non-target metrics, not accuracy alone. Because non-target events are more frequent, a model can appear accurate while missing most targets. The confusion matrix and target recall expose that failure.

Change collection.py → OUTPUT_NPZ to a new path under data/testing/ and collect another session. Do not include this recording in training. Set the model and test paths in testing.py, then run:

Terminal window
python testing.py

The script applies the saved scaler and LDA model, then reports accuracy, confusion matrix, and classification metrics using its configured probability threshold (default 0.70). An unseen session is a much stronger test than re-evaluating data the model already saw.

Before playing the game, set SERIAL_PORT and MODEL_FILE in online_test.py, then run:

Terminal window
python online_test.py

Focus on one square and press Space. Every cell flashes three times, producing 27 marker-aligned epochs. For each flash, the saved LDA model returns P(target)P(\text{target}). The script averages the three probabilities for each cell and highlights the highest-scoring one.

The configured threshold is printed as a diagnostic; final selection uses the largest average probability. Repeat this test with different intended cells. Move on to the game only when live selections are consistently reliable.

Set SERIAL_PORT and MODEL_FILE in game.py, then run:

Terminal window
python game.py

You play as X against the computer’s O. On each turn:

  1. Focus on the empty cell where you want to place X.
  2. Press Space while keeping your gaze on that cell.
  3. Each currently available cell flashes three times.
  4. The game averages the target probabilities and highlights its selection.
  5. Your X is placed, then the computer chooses its move.

Only empty cells are included in the flash sequence, so selections become faster as the board fills. The computer first tries to win, then blocks an immediate player win, prefers the center, and then chooses a corner or another available cell.

p300/
├── collection.py # collect labelled, marker-aligned P300 epochs
├── training.py # extract features, scale them, and train LDA
├── testing.py # evaluate the model on an unseen recording
├── online_test.py # test one live 3 × 3 P300 selection
├── game.py # play tic-tac-toe with live P300 selections
├── requirements.txt # Python dependencies
├── environment.yml # reproducible Python 3.11 Conda environment
├── data/
│ ├── training/ # training .npz recordings
│ └── testing/ # held-out .npz recordings
├── json/ # collection metadata and timing logs
├── models/ # saved classifier/scaler .pkl files
└── other/
├── view.py # plot target and non-target ERPs
└── combine.py # merge recordings from multiple sessions

The board does not connect. Confirm the port in every live script and close other applications using it. The scripts cannot share the board connection with the EXG Visualizer at the same time.

Channels are flat or very noisy. Let all eight chon and rldadd commands finish. Check headset contact in the visualizer, part hair beneath the electrodes, and minimize jaw, eye, cable, and head movement during trials.

The log says markers or complete epochs were skipped. Keep the Pygame window open and the computer awake, and avoid running heavy applications during collection. The code skips an event rather than saving an incorrectly aligned epoch when its marker or complete −200 to +850 ms window is unavailable.

Offline accuracy is high but live selection is poor. Test on a genuinely separate session and check target recall. Reusing training data can hide overfitting. Keep the montage, channel order, lighting, viewing distance, posture, and attention strategy consistent between collection and live use.

The model fails validation online. Verify that MODEL_FILE points to the model produced by the current training.py. The live scripts require a 125 Hz, 131-sample mean_windows model with six features for each live EEG channel.