Interactive exhibition prototype — three synchronized browser windows share state via a central WebSocket server. The controller window on machine 2 drives exhibit switching; two display windows on machine 1 react in real time.
┌────────────────────────────────────────────────────────────────┐
│ Machine 1 │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ app-display-1 │ │ app-display-2 │ │
│ │ :3002 │ │ :3003 │ │
│ │ ┌───────────┐ │ │ ┌───────────┐ │ │
│ │ │ iframe │ │ │ │ iframe │ │ │
│ │ └───────────┘ │ │ └───────────┘ │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ socket.io │ socket.io │
│ └──────────┬────────────┘ │
│ │ │
│ ┌───────▼──────┐ ┌──────────────┐ │
│ │ server │ │ sub-apps │ │
│ │ :3000 │ │ :4000 │ │
│ └───────┬──────┘ └──────────────┘ │
└──────────────────────┼─────────────────────────────────────────┘
│ socket.io
┌──────────────────────┼─────────────────────────────────────────┐
│ │ Machine 2 │
│ ┌──────────▼──────────┐ │
│ │ app-controller │ │
│ │ :3001 │ │
│ │ ┌───────────────┐ │ │
│ │ │ [Def][Liv][Spo][Cul] │ ← exhibit menu │
│ │ ├───────────────┤ │ │
│ │ │ iframe │ │ │
│ │ └───────────────┘ │ │
│ └─────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
- User clicks a menu button in the controller (machine 2)
- Controller sends
stateChangeto the socket server (machine 1) - Server broadcasts
stateUpdateto all connected clients - Each main app fades out the old iframe, fades in the new one
- The active iframe receives the full state via
postMessage - Sub-apps can also send state changes upward via
window.parent.postMessage
| Service | Machine | Port | Description |
|---|---|---|---|
server |
1 | 3000 | Socket.io state server |
app-display-1 |
1 | 3002 | Display app — first window |
app-display-2 |
1 | 3003 | Display app — second window |
sub-apps |
1 | 4000 | Static server for all sub-app boilerplates |
app-controller |
2 | 3001 | Controller app with exhibit menu |
├── config.json # ← set serverIp here before running on two machines
├── server/ # Socket.io state server
│ └── index.js
├── app-controller/ # Controller app (menu + iframe) — machine 2
│ ├── server.js
│ └── public/
│ ├── index.html
│ └── app.js
├── app-display-1/ # Display app — machine 1
│ ├── server.js
│ └── public/
│ ├── index.html
│ └── app.js
├── app-display-2/ # Display app — machine 1
│ ├── server.js
│ └── public/
│ ├── index.html
│ └── app.js
└── sub-apps/ # 12 sub-app boilerplates (4 exhibits × 3 views)
├── server.js
├── default/ # Blue theme — pulsing rings
│ ├── controller/index.html
│ ├── display-1/index.html
│ └── display-2/index.html
├── living/ # Green theme — animated bar chart
│ ├── controller/index.html
│ ├── display-1/index.html
│ └── display-2/index.html
├── sports/ # Red theme — rotating dial
│ ├── controller/index.html
│ ├── display-1/index.html
│ └── display-2/index.html
└── culture/ # Purple theme — rotating crystal
├── controller/index.html
├── display-1/index.html
└── display-2/index.html
Run once on both machines after cloning the repo:
npm run install:allEdit config.json at the root of the project and set serverIp to machine 1's IP address on the local network:
{
"serverIp": "192.168.1.100"
}For single-machine testing leave it as
"localhost".
All apps read this file — no environment variables needed.
Starts all five servers in one terminal with colour-coded output:
npm startThen open all three app windows:
npm run open:apps # Chrome
npm run open:apps:safari # SafariMachine 1 — after setting serverIp in config.json:
npm run start:machine-1
npm run open:apps:machine-1 # opens display-1 and display-2Machine 2 — copy the repo (with the updated config.json) to machine 2, then:
npm run start:machine-2
npm run open:apps:machine-2 # opens the controllerBoth machines must be on the same network.
The global state object lives in server/index.js:
let state = {
activeExhibit: 'default' // add more fields here
};Any connected client can patch it:
socket.emit('stateChange', { myNewField: 'value' });All clients (and their active iframes via postMessage) receive the merged update immediately.
- Create a new folder under
sub-apps/(e.g.nature/) with three sub-folders, each containing anindex.html:controller/,display-1/,display-2/. - Add
<iframe id="iframe-nature">to each main app'sindex.html. - Add
'nature'to theEXHIBITSarray in each main app'sapp.js. - Add a menu button in
app-controller/public/index.htmlwithdata-exhibit="nature".
Sub-apps can trigger global state changes without a direct socket connection:
window.parent.postMessage({
type: 'stateChange',
payload: { activeExhibit: 'living' }
}, '*');The parent main app relays this to the socket server, which broadcasts to all clients.
window.addEventListener('message', (event) => {
if (event.data?.type === 'stateUpdate') {
const state = event.data.payload;
// update your UI
}
});Each sub-app runs inside an <iframe>. JavaScript errors thrown inside an iframe do not propagate to the parent window, so a broken sub-app cannot crash the main display. All three iframes are pre-loaded at startup and toggled with CSS opacity — sub-app state is preserved across exhibit switches.