A self-hosted LAN game where participants guess whether images are AI-generated or real photographs. Designed for classroom and lab events — no internet required, runs entirely on your local network.
- How It Works
- Features
- Project Structure
- Requirements
- Setup & Deployment
- Image Dataset Format
- Configuration
- Admin Dashboard
- Scoring
- Data & Logs
- Multiple Batches / Shared PCs
- How the Manifest Works
- API Reference
- Troubleshooting
Participant Server Admin
│ │ │
│ Open browser → / │ │
│ Enter name + confirm ───► │ POST /api/login │
│ ◄─── │ Sets session cookie │
│ │ Starts countdown timer │
│ Game starts │ │
│ Image shown ◄─── │ GET /api/next-image │
│ Click "Bot" or "Not" ───► │ POST /api/answer │
│ ◄─── │ Returns correct/wrong │
│ ... repeats until time up │ Writes to responses.jsonl │
│ │ Updates leaderboard.json │
│ Redirected to /results │ │
│ View leaderboard ◄─── │ GET /api/leaderboard │
│ │ │
│ │ ◄────────────────── │ GET /admin
│ │ ◄────────────────── │ See live sessions, scores, logs
│ │ ◄────────────────── │ Kick players, adjust duration
│ │ ◄────────────────── │ Rebuild leaderboard / export CSV
- Zero internet dependency — fully self-hosted on your LAN
- Opaque image IDs — folder structure (
bot/not) is never exposed to clients - Live leaderboard — auto-refreshes every 10 seconds
- Admin dashboard — live sessions, logs, config, kick players, export CSV
- Quit mid-game — with confirmation dialog
- Start confirmation — two-step flow before the timer begins
- Session timer — configurable per-event from the admin panel
- Batch-safe — same PC can be used by multiple participants back-to-back
- Dot-folder exclusion — rename any image folder to
.foldernameto exclude it without deleting - Rebuild leaderboard — reconstruct from raw logs if
leaderboard.jsonis edited or corrupted - CSV export — download all responses for offline analysis
botOrNot/
├── server.js # Express server — all API routes
├── config.json # Runtime config (session duration)
├── package.json
├── .env # Secrets — DO NOT commit (see .env.example)
├── .env.example # Template for environment variables
│
├── scripts/
│ └── build-manifest.js # One-time script: scans images, generates data/manifest.json
│
├── public/ # Static frontend (served by Express)
│ ├── index.html # Login / start page
│ ├── game.html # Main game (image + Bot/Not buttons)
│ ├── results.html # Post-game score breakdown
│ ├── leaderboard.html # Public live leaderboard
│ ├── admin-login.html # Admin login page
│ ├── admin.html # Admin dashboard
│ └── style.css # Shared stylesheet
│
├── images/ # Your image dataset (gitignored — manage locally)
│ └── datasets/
│ ├── bot/ # AI-generated images go here
│ └── not/ # Real photographs go here
│
└── data/ # Generated at runtime (gitignored)
├── manifest.json # ID → file path mapping (generated by build-manifest.js)
├── responses.jsonl # Append-only log of every answer
└── leaderboard.json # Live leaderboard (updated on every answer)
- Node.js v18 or higher
- npm v8 or higher
- A machine connected to your LAN (wired recommended for stability)
- Your image dataset organized into
bot/andnot/folders
Check your Node version:
node --versiongit clone https://github.com/yourusername/botOrNot.git
cd botOrNotnpm installPlace your images into the correct folders:
images/
└── datasets/
├── bot/ ← AI-generated images (any depth of subfolders is fine)
└── not/ ← Real photographs
- Supported formats:
.jpg,.jpeg,.png,.webp,.gif,.bmp,.tiff,.avif - Subfolders are scanned recursively — you can organize freely inside
bot/andnot/ - To exclude a subfolder without deleting it, prefix its name with a dot:
.excluded_folder
Copy the example file and fill in your values:
cp .env.example .envEdit .env:
ADMIN_PASSWORD=yourpassword
# Generate a secure random secret:
# npm run generate-secret
SESSION_SECRET=paste_generated_secret_hereGenerate a session secret:
npm run generate-secretThis scans your image folders and creates data/manifest.json — a mapping of opaque random IDs to real file paths. Run this once before the event. Do not re-run it during an active event (it changes all image IDs and breaks active sessions).
npm run build-manifestOutput example:
[INFO] Scanning image directories...
[INFO] Found 80 bot images, 9 real images.
[INFO] Assigning IDs to bot images...
[INFO] Assigning IDs to real images...
[INFO] Writing manifest.json...
[DONE] Manifest written: 89 total entries.
80 bot, 9 real.
File size: 0.0 MB
No files were copied or renamed. Your dataset is untouched.
You can now run: node server.js
To regenerate with new IDs (e.g. after adding new images):
npm run build-manifest-force
⚠️ --forcechanges all image IDs. Do this only before an event starts, never mid-competition.
Edit config.json to set how many minutes each participant gets:
{
"sessionDurationMinutes": 5
}You can also change this live from the Admin Dashboard without restarting the server. Changes apply to new sessions only — participants already playing keep their original duration.
npm startYou'll see:
┌─────────────────────────────────────────────┐
│ Bot or Not — Server Running │
├─────────────────────────────────────────────┤
│ Local: http://localhost:6767 │
│ LAN: http://192.168.1.42:6767 │
│ Admin: http://192.168.1.42:6767/admin-login.html │
├─────────────────────────────────────────────┤
│ Images: 89 total (80 bot, 9 real) │
└─────────────────────────────────────────────┘
For development with auto-restart on file changes:
npm run devimages/datasets/
├── bot/ # Anything inside is treated as AI-generated
│ ├── dalle/
│ │ ├── image1.png
│ │ └── image2.jpg
│ ├── midjourney/
│ │ └── img.webp
│ └── .excluded_set/ # Dot-prefix = skipped entirely
│ └── ...
│
└── not/ # Anything inside is treated as real
├── unsplash/
│ └── photo.jpg
└── flickr/
└── real_photo.png
Key rules:
- The
bot/andnot/folder names are the labels — everything inside is labeled accordingly - Sub-folder names are irrelevant and never shown to participants
- Any folder starting with
.is silently skipped at manifest build time - Small or corrupt image files are included — if they can't load in the browser, the game shows "Image failed to load" and continues
Controls the session timer. Editable live from the Admin Dashboard.
| Field | Type | Default | Description |
|---|---|---|---|
sessionDurationMinutes |
number | 1 |
How long each participant's session lasts |
| Variable | Required | Description |
|---|---|---|
ADMIN_PASSWORD |
Yes | Plaintext password for the admin dashboard |
SESSION_SECRET |
Yes | Random string to sign the admin cookie |
PORT |
No | Server port (default: 6767) |
Navigate to http://<your-ip>:6767/admin-login.html
What you can do:
| Section | What it shows / does |
|---|---|
| Overview | Total participants, answers, correct count, overall accuracy |
| Live Sessions | Currently active players — name, IP, images seen, time remaining |
| Kick | Terminate any player's session instantly (with confirmation) |
| Settings | Change session duration for future logins |
| Rebuild Leaderboard | Recalculate leaderboard.json from responses.jsonl (with confirmation) |
| All Responses | Full log of every answer — newest first |
| Download CSV | Export responses.jsonl as a CSV for offline analysis |
The dashboard auto-polls every 5 seconds for sessions and 10 seconds for logs.
- Correct answer: +10 points
- Wrong answer: 0 points
- Leaderboard is sorted by points (descending), then by total answered (descending) as a secondary sort
The leaderboard is updated live on every answer — no need to refresh.
Append-only log — one JSON object per line, one per answer. Never modified in-place.
{"username":"Alice","privateIp":"192.168.1.5","imageId":"a3f1bc...","trueLabel":"bot","choice":"bot","correct":true,"pointsAwarded":10,"timestamp":"2025-07-15T12:00:01.000Z","responseTimeMs":2341}
{"username":"Alice","privateIp":"192.168.1.5","imageId":"d92c4a...","trueLabel":"not","choice":"bot","correct":false,"pointsAwarded":0,"timestamp":"2025-07-15T12:00:04.000Z","responseTimeMs":1102}Updated on every answer. Keyed by username.
{
"Alice": {
"username": "Alice",
"privateIp": "192.168.1.5",
"totalAnswered": 12,
"correctCount": 9,
"points": 90,
"lastUpdated": "2025-07-15T12:01:44.000Z"
}
}If this file gets corrupted (e.g. manual edit gone wrong), use the Rebuild Leaderboard button in the admin dashboard to regenerate it cleanly from responses.jsonl.
Maps random IDs → real file paths. Generated by build-manifest.js. Never expose this file publicly — it contains the real paths and labels of every image.
The game is designed for lab environments where the same computer is used by multiple participants in sequence:
- When a participant finishes, they are sent to the results page
- The next participant opens
http://<server-ip>:6767/(the login page) in the browser - Opening the login page clears the previous player's local session data automatically
- The new participant enters their name and plays fresh
Username conflict: Each username must be unique within an active session window. If a player's timer has expired, their name is freed and can be reused. If two batches overlap and someone wants to reuse an active name, the server returns an error — they should pick a different name or wait.
Participant IP tracking: The server records each participant's LAN IP address (privateIp) in all logs and the leaderboard. This is captured from the request on login — it does not require any input from the participant.
The manifest is the core security mechanism that prevents cheating:
build-manifest.jsscansimages/datasets/bot/andimages/datasets/not/- Each image gets a random 16-character hex ID (e.g.
a3f1bc8e72d04c91) - The manifest stores:
{ id, label, filename, _path }— server-side only - When a participant requests an image, the server serves it at
/api/image/<id> - The browser only ever sees the opaque ID — never the path, folder name, or label
- The label (
bot/not) is only revealed after the participant submits their answer
This means:
- Participants cannot look at the URL and know the answer
- No files are copied or renamed — zero extra disk usage
- The dataset remains completely untouched
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/login |
None | Start a session. Body: { username } |
POST |
/api/logout |
Cookie | Clear session cookie |
GET |
/api/next-image |
Cookie | Get next image ID and URL |
GET |
/api/image/:id |
None | Serve image bytes (ETag cached) |
POST |
/api/answer |
Cookie | Submit answer. Body: { imageId, choice, responseTimeMs } |
GET |
/api/session-status |
Cookie | Get remaining session time |
POST |
/api/quit |
Cookie | End session early |
GET |
/api/leaderboard |
None | Get current sorted leaderboard |
| Method | Path | Description |
|---|---|---|
POST |
/api/admin/login |
Authenticate admin. Body: { password } |
GET |
/api/admin/logs |
All responses + summary stats |
GET |
/api/admin/config |
Get current config |
POST |
/api/admin/config |
Update session duration |
GET |
/api/admin/active-sessions |
List active player sessions |
POST |
/api/admin/kick-session |
Terminate a player session. Body: { username } |
POST |
/api/admin/rebuild-leaderboard |
Rebuild leaderboard.json from responses.jsonl |
GET |
/api/admin/export |
Download all responses as CSV |
You haven't created a .env file. Run:
cp .env.example .env
# then edit .env and set ADMIN_PASSWORD and SESSION_SECRETYou haven't run the manifest builder yet:
npm run build-manifestYour image folders are empty or in the wrong location. Check that:
images/datasets/bot/contains at least one imageimages/datasets/not/contains at least one image- Image files have supported extensions (
.jpg,.png,.webp, etc.)
- Make sure the server is bound to
0.0.0.0(it is by default) - Check your firewall isn't blocking port
6767 - On Linux:
sudo ufw allow 6767 - Share the LAN IP shown at startup, not
localhost
- Check
ADMIN_PASSWORDin your.envmatches what you're typing - Passwords are case-sensitive
- Check that
data/manifest.jsonexists and was generated from the correct image paths - If you moved the
images/folder after building the manifest, regenerate:npm run build-manifest-force
The previous participant's session hasn't expired yet, or the admin hasn't kicked it. Options:
- Wait for the timer to expire
- Admin: open the dashboard and click Kick next to that player's row
- Use a different username
The leaderboard.json may be out of sync (e.g. after a manual edit). Use Rebuild Leaderboard in the admin dashboard — it recalculates everything from responses.jsonl which is the authoritative source.
All answers written to responses.jsonl before the crash are safe (append-only file). Active session state (who was playing, their timers) is lost since it's in-memory. Restart the server — participants will need to log in again. Use Rebuild Leaderboard after restart to restore scores.
MIT