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
1. What Is the P300 and Why Does It Work?
Section titled “1. What Is the P300 and Why Does It Work?”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.
2. Where to Place the Electrodes
Section titled “2. Where to Place the Electrodes”P300 responses are commonly strongest over central-parietal and parieto-occipital regions. The montage used to develop this repository is:
| Channel | Position | Region |
|---|---|---|
| 1 | P3 | Left parietal |
| 2 | P1 | Left medial parietal |
| 3 | Pz | Midline parietal |
| 4 | P2 | Right medial parietal |
| 5 | P4 | Right parietal |
| 6 | PO3 | Left parieto-occipital |
| 7 | POz | Midline parieto-occipital |
| 8 | PO4 | Right 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.
3. Setting Up the Knight Board
Section titled “3. Setting Up the Knight Board”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:
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.
4. How Training Trials Work
Section titled “4. How Training Trials Work”The collection settings live at the top of collection.py:
| Setting | Repository default |
|---|---|
| Grid | 3 × 3 cells |
| Trials | 15 |
| Flashes per trial | 20 |
| Target flashes per trial | 5 |
| Non-target flashes per trial | 15 |
| Flash duration | 100 ms |
| Sampling rate | 125 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:
- Draw the selected cell in white.
- Insert a unique value into BrainFlow’s marker channel at stimulus onset.
- Keep the cell white for 100 ms.
- Wait for the complete post-stimulus response to arrive.
- 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.
5. Marker-Aligned Epochs
Section titled “5. Marker-Aligned Epochs”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.
6. Pre-Processing
Section titled “6. Pre-Processing”Collection, offline evaluation, online selection, and the game use the same pre-processing steps:
- Linear detrending removes slow drift from each channel.
- A 0.5–30 Hz fourth-order Butterworth band-pass preserves the slow ERP while reducing movement and high-frequency muscle noise.
- The epoch is cut from −200 to +850 ms around the marker.
- 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.
7. Features and LDA Classification
Section titled “7. Features and LDA Classification”training.py turns each channel into six features. Five are mean amplitudes in
successive windows after the flash:
| Window | What it captures |
|---|---|
| 0–200 ms | Early visual response / N1–P2 |
| 200–350 ms | N2 and early P300 activity |
| 350–500 ms | Main P300 rise |
| 500–650 ms | Late P300 activity |
| 650–800 ms | Later ERP tail |
The sixth feature is a contrast:
With eight channels, that produces 48 features per flash. The pipeline then:
- Makes a stratified 75/25 training and holdout split.
- Fits
StandardScaleron the training features. - Trains
LinearDiscriminantAnalysiswith equal class priors, preventing the more common non-target class from dominating solely because it has more examples. - Reports holdout accuracy, a confusion matrix, and a classification report.
- Runs stratified five-fold cross-validation on the full dataset.
- Saves the classifier, scaler, feature method, sampling rate, epoch shape,
feature count, and validation scores together in one
.pklmodel.
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.
8. Install and Configure
Section titled “8. Install and Configure”Install
Section titled “Install”The project uses Python 3.11. Clone it and create the supplied Conda environment:
git clone https://github.com/NeuroPawn/p300.gitcd p300conda env create -f environment.ymlconda activate neuropawnAlternatively, create the environment and install the four dependencies directly:
conda create -n neuropawn python=3.11conda activate neuropawnpip install -r requirements.txtThe requirements are BrainFlow, NumPy, Pygame, and scikit-learn.
Configure
Section titled “Configure”The repository keeps configuration constants at the top of each script. Check these paths before running each stage:
| File | Values to set |
|---|---|
collection.py | SERIAL_PORT, OUTPUT_NPZ, OUTPUT_LOG |
training.py | INPUT_DATA, OUTPUT_MODEL |
testing.py | MODEL_FILE, TEST_DATA_FILE, threshold |
online_test.py | SERIAL_PORT, MODEL_FILE |
game.py | SERIAL_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.
9. Collect, Train, and Evaluate
Section titled “9. Collect, Train, and Evaluate”1. Collect training data
Section titled “1. Collect training data”Set OUTPUT_NPZ to a file under data/training/, connect the board, put on the
headset, and run:
python collection.pyFollow 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.
2. Inspect the ERP
Section titled “2. Inspect the ERP”Set other/view.py → NPZ_FILE to the recording and run:
python other/view.pyThe 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.
3. Train the model
Section titled “3. Train the model”Point training.py → INPUT_DATA at the training recording or combined dataset,
choose OUTPUT_MODEL, then run:
python training.pyReview 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.
4. Test on a separate recording
Section titled “4. Test on a separate recording”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:
python testing.pyThe 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.
10. Test Live Selection
Section titled “10. Test Live Selection”Before playing the game, set SERIAL_PORT and MODEL_FILE in
online_test.py, then run:
python online_test.pyFocus on one square and press Space. Every cell flashes three times, producing 27 marker-aligned epochs. For each flash, the saved LDA model returns . 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.
11. Play P300 Tic-Tac-Toe
Section titled “11. Play P300 Tic-Tac-Toe”Set SERIAL_PORT and MODEL_FILE in game.py, then run:
python game.pyYou play as X against the computer’s O. On each turn:
- Focus on the empty cell where you want to place X.
- Press Space while keeping your gaze on that cell.
- Each currently available cell flashes three times.
- The game averages the target probabilities and highlights its selection.
- 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.
12. Project Layout
Section titled “12. Project Layout”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 sessions13. Troubleshooting
Section titled “13. Troubleshooting”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.
