This document explains how the countdown timer synchronization works between clients and server for developer onboarding.
The countdown timer uses a server-owned architecture:
┌─────────────┐ commands ┌─────────────┐ broadcast ┌─────────────┐
│ Client A │ ─────────────────▶│ Server │◀─────────────────▶│ Client B │
│ (sender) │ │ (owner) │ │ (receiver) │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ countdown_start_sequence │ │
│ ──────────────────────────────▶│ │
│ │ sync (with countdown state) │
│ │ ────────────────────────────────▶│
│ │ │
│ sync (with countdown state) │ │
│◀────────────────────────────── │ │
Key principle: Clients send commands, server owns state, server broadcasts to all.
| File | Role |
|---|---|
oscillaTimers.js |
Client-side UI, countdown controls, display updates |
oscillaSystemSocket.js |
WebSocket message routing on client |
server.js |
Server-side countdown logic, state management, broadcasting |
// Key: "oscilla.countdownSequences"
[
{
"name": "Sonata",
"loop": 1, // 0 = infinite, 1+ = repeat count
"chain": null, // index of next sequence, or null
"cues": [
{ "name": "Exposition", "seconds": 120 },
{ "name": "Development", "seconds": 180 }
]
}
]
sharedState.countdown){
running: true,
cueName: "Exposition", // Current cue name (for display)
totalSeconds: 120, // Total duration of current cue
startTime: 1706745600000, // Date.now() when cue started
sequenceName: "Sonata", // Parent sequence name
cueIndex: 0, // Current cue index within sequence
totalCues: 2, // Total cues in sequence
loop: 1, // Loop setting
currentLoop: 1, // Current loop iteration
cues: [...], // Full cue array (for advancement)
chainTo: null // Next sequence index when complete
}
sharedState.countdownSequences)// Mirror of client sequences, stored on server for:
// 1. Late-joining clients
// 2. Sequence lookup when starting by index
[
{ name: "Sonata", loop: 1, chain: null, cues: [...] }
]
| Message | Purpose | Payload |
|---|---|---|
countdown_sequences_update |
Sync sequences to server | { sequences: [...] } |
countdown_start_sequence |
Start a sequence | { sequenceIndex: 0, sequence: {...} } |
countdown_start_cue |
Start single cue | { cue: { name, seconds } } |
countdown_stop |
Stop countdown | {} |
The countdown state is included in the regular sync message:
{
type: "sync",
state: {
// ... other state ...
countdown: {
running: true,
cueName: "Exposition",
remainingSec: 45, // Calculated from startTime
sequenceName: "Sonata",
cueIndex: 0,
totalCues: 2,
// ... etc
},
countdownSequences: [...] // For late-joiners
}
}
Client Server
│ │
│──── WebSocket connect ────────────▶│
│ │
│◀─── sync (includes countdown) ─────│ // If countdown running
│ │
│──── countdown_sequences_update ───▶│ // Client syncs its sequences
│ │
Code path:
oscillaSystemSocket.js: Socket open event calls window.syncCountdownSequences()oscillaTimers.js: syncSequencesToServer() sends countdown_sequences_updateClient A Server Client B
│ │ │
│── countdown_sequences_update ─────▶│ │
│── countdown_start_sequence ───────▶│ │
│ │── startServerSequence() ───────▶│
│ │ │
│◀────────── sync ───────────────────│─────────── sync ───────────────▶│
│ │ │
Code path:
oscillaTimers.js: sendCountdownStartSequence(index) sends:
countdown_sequences_update (ensures server has sequences)countdown_start_sequence with index AND full sequence dataserver.js: Receives message, calls startServerSequence(seq)server.js: Sets sharedState.countdown, immediately calls broadcastState()window.updateCountdownDisplay(countdown)Server All Clients
│ │
│ updateLoop() every 250ms │
│ ├── if countdown.running │
│ │ └── broadcastState() │
│ │ └── calculates remainingSec │
│ │
│────────── sync ─────────────────────▶│
│ │ updateCountdownDisplay()
│ │
Code path:
server.js: updateLoop() runs every 250mssharedState.countdown.running OR sharedState.isPlaying, calls broadcastState()broadcastState() calculates remainingSec from startTime:const elapsed = Math.floor((Date.now() - countdown.startTime) / 1000);
const remainingSec = Math.max(0, countdown.totalSeconds - elapsed);
Server (in broadcastState)
│
│ remainingSec === 0?
│ └── advanceCountdown()
│ ├── cueIndex < totalCues - 1?
│ │ └── advance to next cue
│ ├── currentLoop < loop?
│ │ └── restart sequence, increment loop
│ ├── chainTo !== null?
│ │ └── start chained sequence
│ └── else: stopServerCountdown()
Code path:
server.js: broadcastState() checks if remainingSec <= 0advanceCountdown() which handles:
Client Server All Clients
│ │ │
│──── countdown_stop ───────────────▶│ │
│ │── stopServerCountdown() ───────▶│
│ │ │
│◀────────── sync ───────────────────│─────────── sync ───────────────▶│
│ (countdown: null) │ (countdown: null) │
// Server stores START time, not remaining time
sharedState.countdown = {
startTime: Date.now(), // When cue started
totalSeconds: 120 // Total duration
};
// In broadcastState(), calculate remaining:
const elapsed = Math.floor((Date.now() - countdown.startTime) / 1000);
const remainingSec = Math.max(0, countdown.totalSeconds - elapsed);
remainingSec at broadcast time// ❌ Don't do this - requires clock synchronization
countdown.endTime = Date.now() + (seconds * 1000);
// Client: remainingSec = (countdown.endTime - Date.now()) / 1000
// Problem: client and server clocks may differ!
oscillaTimers.js)// Sync sequences to server on load/connect
function syncSequencesToServer()
// Send start command with sequence data
function sendCountdownStartSequence(sequenceIndex)
// Send single cue start
function sendCountdownStartCue(cue)
// Send stop command
function sendCountdownStop()
// Broadcast sequences to server
function broadcastSequencesUpdate()
// Update display from server sync (called by oscillaSystemSocket)
function updateCountdownDisplay(countdown)
server.js)// Start a single cue countdown
function startServerCountdown(cue, sequenceName, cueIndex, totalCues, loop, currentLoop)
// Start a full sequence
function startServerSequence(sequence, loopCount)
// Advance to next cue/loop/chain
function advanceCountdown()
// Stop and clear countdown
function stopServerCountdown()
// Broadcast state to all clients (includes countdown)
function broadcastState()
server.js switch statement)case "countdown_sequences_update":
// Store sequences, broadcast to others
sharedState.countdownSequences = data.sequences;
broadcastToOthers(ws, data);
break;
case "countdown_start_sequence":
// Start sequence (prefer sent data, fallback to stored)
const seq = data.sequence || sharedState.countdownSequences[data.sequenceIndex];
if (seq) startServerSequence(seq);
break;
case "countdown_start_cue":
// Start single cue
startServerCountdown(data.cue);
break;
case "countdown_stop":
// Stop countdown
stopServerCountdown();
break;
oscillaSystemSocket.js)case "sync":
handleSyncMessage(data); // Includes countdown display update
break;
case "countdown_sequences_update":
// Another client updated sequences
handleCountdownMessage(data);
break;
The server runs an update loop every 250ms:
const updateLoop = () => {
const countdownRunning = sharedState.countdown && sharedState.countdown.running;
if (sharedState.isPlaying) {
updateElapsedTime();
broadcastState();
} else if (countdownRunning) {
// Countdown runs independently of playback!
broadcastState();
}
setTimeout(updateLoop, 250);
};
Important: Countdown broadcasts happen even when score playback is stopped. This was a bug that was fixed — the original code only broadcast when isPlaying was true.
When a client connects mid-countdown:
sync messagesync.state.countdown contains current countdown state with calculated remainingSecsync.state.countdownSequences contains all sequences// In handleSyncMessage (oscillaSystemSocket.js)
if (state.countdown) {
window.updateCountdownDisplay(state.countdown);
}
if (state.countdownSequences?.length > 0) {
// Store to localStorage if we don't have any
const local = localStorage.getItem("oscilla.countdownSequences");
if (!local || JSON.parse(local).length === 0) {
localStorage.setItem("oscilla.countdownSequences", JSON.stringify(state.countdownSequences));
}
}
[Countdown] ✅ Sequences updated from client: 2 sequences
[Countdown] Names: Warm-up, Sonata
[Countdown] ▶ Sequence started: Sonata (2 cues, loop: 1)
[Countdown] ▶ Started: Exposition (120s)
[Countdown] → Advanced to cue 2/2: Development
[Countdown] ⏹ Stopped
[Countdown] Broadcasting sequences to server: 2 sequences
[Countdown] Sent countdown_start_sequence with sequence data: Sonata
[Countdown] ✅ Synced sequences to server: 2
| Symptom | Cause | Fix |
|---|---|---|
| Countdown doesn't start | Sequences not synced to server | Check broadcastSequencesUpdate() is called |
| Display doesn't update | updateLoop not broadcasting |
Ensure countdown check in update loop |
| Late joiner sees wrong time | remainingSec not in sync message |
Check broadcastState() calculates it |
| Sequences lost on refresh | Not saved to localStorage | Check saveCountdownSequences() |
remainingSec computed from startTime at broadcast