Sparka deck guide
How to make an HTML presentation interactive with Sparka. Follow every MUST exactly. Restyle freely, but keep the behavior of the reference code.
<!-- sparka:part rules -->
3.1 Rules
Setup
- One questions block. Paste the
embed_blockfromsparka_presentation_saveexactly once:<script type="application/json" id="sparka-questions">{"presentation_id":"pr_…","questions":[…]}</script>. It MUST NOT contain correct answers. For a deck that fetches questions instead, the block holds only{"presentation_id":"pr_…"}and the deck renders question text fromGET https://sparka.io/v1/presentations/{id}. - Never put the secret key in the deck. Nothing starting with
spk_live_ever appears in HTML/JS. The deck uses only thepresentation_id, public endpoints, and thecontrol_tokenit receives at Start. - Question slides carry
data-question="<question id>"on the slide element. Exactly one slide per question ID. Inside, put an element with classsparka-livewhere counts/results render, and a Reveal button (class="sparka-reveal"). - Join slide. The title slide (or a dedicated slide right after it) has
data-sparka-joinand contains slots for the QR (.sparka-qr), the join URL (.sparka-url) and the PIN (.sparka-pin), hidden until a session exists. SHOULD also repeat a small PIN badge on question slides.
Starting and ending
5. Explicit Start button. A visible "Start live session" button calls
POST https://sparka.io/v1/presentations/{id}/sessions. The deck MUST NOT
start a session on page load, on slide visibility, or on navigation.
6. Start is create-or-resume. Calling it again (after reload, or a second
click) returns the same live session. After a reload, if the deck remembers
it had a session (sessionStorage marker), relabel the button "Resume live
session" — still a click, never automatic.
7. Show PIN + QR + URL from the response (pin, qr_svg, join_url) on
the join slide. qr_svg is server-generated SVG and may be inserted as
markup; display join_url without https://.
8. Explicit End button (with confirm()) sends end over the WebSocket;
if the socket is down it calls POST /v1/sessions/{session_id}/end with
Authorization: Bearer <control_token> (01 §2.2.4). Then it clears
session state and restores the Start button. Sessions also end on their
own after 2 h idle.
Driving the live question
9. Slide change → set_current. On every slide change after Start, send
set_current {question_id} when the new slide has data-question, and
set_current {question_id: null} when it doesn't. Send only when the value
changes, and re-send the current value after every welcome (i.e. after
each (re)connect has authenticated). Messages sent between hello and
welcome are queued by the server (up to 8, 02 §2.2), so sending early is
safe but never a substitute for the re-send after welcome. Going back to
a question is safe: answers are kept. Every message carries "v": 1
(02 §4.1).
10. Reveal is a button on question slides plus the R key, sending
reveal {question_id} for the current question. Never auto-reveal.
11. Keyboard handlers MUST ignore keys while focus is in a button, input or
textarea, so Space/Enter on Start doesn't also advance the slide.
Live display
12. Render from WebSocket messages only (02 §4.3): welcome → full state
(replace local state); counts → bars (poll, options keyed by letter
id), a word list (word_cloud, terms), or just "N answered" (quiz shows
no per-option counts until reveal); question/timer → countdown;
revealed → mark results.correct, final results.options, and the
leaderboard (leaderboard.top, leaderboard_size rows);
session_ended → end locally.
13. Countdown uses server time: offset = server_now - Date.now() at
receipt; remaining = ends_at - (Date.now() + offset). Never run a local
timer from time_limit_s.
14. Participant text is untrusted. Words, nicknames: set with
textContent, never innerHTML.
15. Option labels come from the server copy of the questions (fetched on load)
so counts line up with what phones show. Answer tiles (D21) MUST label
option i with letter A–J and, for A–F, the same shape and color the
phones use: A ▲ #DC2626, B ◆ #2563EB, C ● #EAB308 (dark text), D ■
#15803D, E ⬢ #7C3AED, F ★ #0E7490, G–J letter only on slate
#334155. Required for phone_display: "answers_only" (phones show only
the tile); recommended always. Keep ≤ 6 options for answers_only.
The WebSocket addresses option i by the lowercase letter (a = 0).
Robustness
16. Verify on load (hard-coded decks): GET /v1/presentations/{id}; for
each embedded question compare every field the embedded copy has. On any
difference or missing ID, show a non-blocking banner: "Live questions
differ from this deck — ask your AI to re-save the presentation." Never
block presenting.
17. Reconnect to the same live_url on close (D18, 02 §8.1): backoff
0.5 s doubling to a 10 s cap with equal jitter (wait between half and all
of the step); after 2 failed attempts add r=1 to the URL (?r=1; &r=1 if it has a query); on
close 4010/4013 retry within 250 ms; reset the backoff once a
welcome arrived and the socket stayed open 10 s. Send
{"v":1,"type":"ping","t":<Date.now()>} every 25 s while open. Stop
reconnecting on session_ended, End, or a "stop" close code (02 §4.4:
4001, 4002, 4003, 4004, 4009, 4100). On 4001, show "Resume live
session" — never re-create a session automatically (D6).
18. Offline-degraded. If Sparka is unreachable (network error, CSP block
in a preview, 5xx), slides still work; show a small status/banner, keep
Start clickable to retry. 503 means live hosting is temporarily
unavailable: say "Live sessions are temporarily unavailable — keep
presenting and try Start again in a few minutes." 402/429/403: show the
API's message (plan or quota). Never alert() in a loop; never throw
uncaught.
19. State in memory. Keep session_id, control_token, live_url in JS
variables. sessionStorage MAY hold only a marker {session_id} keyed by
presentation ID, wrapped in try/catch (storage can be blocked). No
localStorage, no cookies.
20. No dependencies. No external scripts, fonts or CDNs are needed for
Sparka; everything is inline. All network calls go to https://sparka.io
(REST) and the live_url (wss://sparka.io/live/…).
21. Tell the user: to present live, open the deck in a normal browser tab
(download the HTML if it was shown in a chat preview).
<!-- sparka:/part -->
<!-- sparka:part html_skeleton -->
3.2 HTML skeleton
<!-- sparka:include skeleton.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Q3 All-Hands</title>
<style>
body { margin: 0; font: 20px/1.4 system-ui, sans-serif; background: #0f1222; color: #f4f5fb; }
.slide { display: none; min-height: 100vh; padding: 6vh 8vw; box-sizing: border-box; }
.slide.active { display: block; }
#sparka-bar { position: fixed; right: 12px; bottom: 12px; display: flex; gap: 8px; align-items: center; font-size: 14px; opacity: .85; z-index: 10; }
#sparka-banner { position: fixed; top: 0; left: 0; right: 0; padding: 8px 16px; background: #7a4b00; font-size: 14px; z-index: 10; }
.sparka-qr svg { width: 240px; height: 240px; background: #fff; padding: 8px; }
.sparka-bar-row { display: flex; align-items: center; gap: 12px; margin: 6px 0; }
.sparka-bar-fill { height: 28px; background: #6c7cff; border-radius: 4px; transition: width .25s; }
.sparka-correct .sparka-bar-fill { background: #2fbf71; }
.sparka-word { display: inline-block; margin: 4px 10px; }
</style>
</head>
<body>
<div id="sparka-banner" hidden></div>
<div id="sparka-bar">
<span id="sparka-status">Live: off</span>
<button id="sparka-start" type="button">Start live session</button>
<button id="sparka-end" type="button" hidden>End session</button>
</div>
<section class="slide" data-sparka-join>
<h1>Q3 All-Hands</h1>
<div class="sparka-join" hidden>
<div class="sparka-qr"></div>
<p>Join at <b class="sparka-url"></b> with PIN <b class="sparka-pin"></b></p>
</div>
</section>
<section class="slide"><h2>Agenda</h2><ul><li>…</li></ul></section>
<section class="slide" data-question="lunch">
<h2>Where should the offsite lunch be?</h2>
<div class="sparka-live"></div>
<p><span class="sparka-total"></span> <span class="sparka-timer"></span></p>
<button class="sparka-reveal" type="button">Reveal</button>
</section>
<section class="slide" data-question="quiz1">
<h2>Which product shipped first?</h2>
<div class="sparka-live"></div>
<p><span class="sparka-total"></span> <span class="sparka-timer"></span></p>
<button class="sparka-reveal" type="button">Reveal</button>
<ol class="sparka-leaderboard"></ol>
</section>
<section class="slide" data-question="oneword">
<h2>One word for this quarter?</h2>
<div class="sparka-live"></div>
<button class="sparka-reveal" type="button">Reveal</button>
</section>
<!-- Paste embed_block from sparka_presentation_save here, exactly once. No answers. -->
<script type="application/json" id="sparka-questions">
{"presentation_id":"pr_01K7ZQ8V6M3T2D4R9XW5HJNB0C","questions":[
{"id":"lunch","type":"poll","prompt":"Where should the offsite lunch be?","options":["Tacos","Sushi","Pizza"],"multiple":false,"phone_results":"on_reveal","time_limit_s":null},
{"id":"quiz1","type":"quiz","prompt":"Which product shipped first?","options":["Atlas","Beacon","Comet"],"time_limit_s":20,"points":1000,"speed_bonus":0.5},
{"id":"oneword","type":"word_cloud","prompt":"One word for this quarter?","max_entries":3,"max_length":30,"phone_results":"live","time_limit_s":null}]}
</script>
<script>/* Sparka live — reference pattern (§3.3) */</script>
<script>
// Deck navigation (the deck's own; it only has to call window.sparkaSlideChanged)
const slides = Array.from(document.querySelectorAll(".slide"));
let idx = 0;
function go(n) {
idx = Math.max(0, Math.min(slides.length - 1, n));
slides.forEach((s, k) => s.classList.toggle("active", k === idx));
if (window.sparkaSlideChanged) window.sparkaSlideChanged(slides[idx]);
}
document.addEventListener("keydown", (e) => {
if (e.target.closest("button, input, textarea, select")) return;
if (["ArrowRight", "PageDown", " "].includes(e.key)) { e.preventDefault(); go(idx + 1); }
if (["ArrowLeft", "PageUp"].includes(e.key)) { e.preventDefault(); go(idx - 1); }
});
go(0);
</script>
</body>
</html>
<!-- sparka:/include --> <!-- sparka:/part -->
<!-- sparka:part reference_code -->
3.3 Reference code
Vanilla, no dependencies, ~200 lines. Constants and message shapes marked
(02) are filled from sparka-core when the guide is rendered; the text
below uses the shapes of 02 §4 (golden fixtures, 02 §10.1).
<!-- sparka:include reference.js -->
(() => {
"use strict";
const API = "https://sparka.io";
const cfg = JSON.parse(document.getElementById("sparka-questions").textContent);
const PID = cfg.presentation_id;
const MARK = "sparka:" + PID; // sessionStorage marker key
const deckQ = new Map((cfg.questions || []).map((q) => [q.id, q]));
let serverQ = new Map(deckQ); // replaced by server copy on load
let ses = null; // {session_id,pin,join_url,qr_svg,control_token,live_url}
let ws = null, tries = 0, failed = 0, pingT = null, retryT = null, stableT = null;
let current = null; // question id on screen, or null
let offset = 0; // server clock - local clock (ms)
const endsAt = {}; // question id -> ends_at (server ms)
const revealed = {}; // question id -> true once results are shown
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const btnStart = $("#sparka-start"), btnEnd = $("#sparka-end");
const slideFor = (id) => $$("[data-question]").find((s) => s.dataset.question === id);
const label = (o) => (typeof o === "string" ? o : o.text); // option shape (01)
const LETTERS = "abcdefghij"; // option i <-> id (02, D21)
const TILES = [["A","▲","#DC2626","#fff"],["B","◆","#2563EB","#fff"],["C","●","#EAB308","#111827"],
["D","■","#15803D","#fff"],["E","⬢","#7C3AED","#fff"],["F","★","#0E7490","#fff"],
["G","","#334155","#fff"],["H","","#334155","#fff"],["I","","#334155","#fff"],["J","","#334155","#fff"]];
const STOP = [4001, 4002, 4003, 4004, 4009, 4100]; // "stop" close codes (02 §4.4)
function mark(v) {
try { v ? sessionStorage.setItem(MARK, JSON.stringify(v)) : sessionStorage.removeItem(MARK); } catch (_) {}
}
function marked() {
try { return JSON.parse(sessionStorage.getItem(MARK) || "null"); } catch (_) { return null; }
}
function banner(msg) { const b = $("#sparka-banner"); if (b) { b.textContent = msg || ""; b.hidden = !msg; } }
function status(msg) { const s = $("#sparka-status"); if (s) s.textContent = msg; }
async function api(method, path, body, token) {
const headers = body ? { "Content-Type": "application/json" } : {};
if (token) headers.Authorization = "Bearer " + token; // control token only, never a secret key
const res = await fetch(API + path, {
method, headers,
body: body ? JSON.stringify(body) : undefined,
});
let data = null;
try { data = await res.json(); } catch (_) {}
if (!res.ok) {
const err = new Error((data && data.error && data.error.message) || "HTTP " + res.status);
err.status = res.status;
throw err;
}
return data;
}
function explain(e) {
if (!e.status) return "Can't reach Sparka from here. If this deck is open inside a chat preview, download it and open the file in your browser.";
if (e.status === 503) return "Live sessions are temporarily unavailable. Keep presenting and try Start again in a few minutes.";
if (e.status === 404) return "This presentation no longer exists on Sparka. Live features are off.";
return e.message; // 402/403/429 etc: the API's own sentence
}
// ---- verify / load questions (rule 16) ----
async function verify() {
try {
const p = await api("GET", "/v1/presentations/" + PID);
if (!p || !Array.isArray(p.questions)) throw Object.assign(new Error("Sparka sent an unexpected reply. Live features may not work."), { status: 500 });
serverQ = new Map(p.questions.map((q) => [q.id, q]));
const diff = [];
for (const [id, q] of deckQ) {
const s = serverQ.get(id);
if (!s) { diff.push(id + " (missing)"); continue; }
for (const k of Object.keys(q)) if (JSON.stringify(q[k]) !== JSON.stringify(s[k])) diff.push(id + "." + k);
}
if (diff.length) banner("Live questions differ from this deck (" + diff.slice(0, 3).join(", ") +
"). Ask your AI to re-save the presentation; phones show the saved version.");
$$("[data-question]").forEach((s) => onCounts(s.dataset.question, {}));
} catch (e) { banner(explain(e)); }
}
// ---- start / resume / end (rules 5-8) ----
async function start() {
btnStart.disabled = true; status("Starting…");
try {
ses = await api("POST", "/v1/presentations/" + PID + "/sessions", {});
mark({ session_id: ses.session_id });
banner("");
showJoin(true);
btnStart.hidden = true; btnEnd.hidden = false;
connect();
} catch (e) {
if (ses) teardown("Live: off"); else status("Live: off");
banner(explain(e));
} finally { btnStart.disabled = false; }
}
function end() {
if (!ses || !confirm("End the live session for everyone?")) return;
if (!send({ type: "end" })) { // socket down: HTTP fallback (01 §2.2.4)
api("POST", "/v1/sessions/" + ses.session_id + "/end", null, ses.control_token)
.catch(() => banner("Couldn't reach Sparka — the session will close by itself after 2 hours idle."));
}
teardown("Live: ended");
}
function teardown(msg) {
ses = null; mark(null); tries = 0; failed = 0;
for (const id in revealed) delete revealed[id];
clearTimeout(retryT); clearTimeout(stableT); clearInterval(pingT);
if (ws) { const w = ws; ws = null; try { w.close(1000); } catch (_) {} }
showJoin(false);
btnStart.hidden = false; btnStart.textContent = "Start live session"; btnEnd.hidden = true;
status(msg);
}
function showJoin(on) {
$$("[data-sparka-join]").forEach((el) => {
const box = $(".sparka-join", el) || el;
box.hidden = !on;
if (!on) return;
const qr = $(".sparka-qr", el); if (qr) qr.innerHTML = ses.qr_svg; // trusted: from Sparka API
const url = $(".sparka-url", el); if (url) url.textContent = ses.join_url.replace(/^https?:\/\//, "");
const pin = $(".sparka-pin", el); if (pin) pin.textContent = ses.pin;
});
}
// ---- WebSocket (rules 9, 12, 17) ----
function send(m) { // every message is {"v":1,"type":…} (02 §4.1)
if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(Object.assign({ v: 1 }, m))); return true; }
return false;
}
function connect() {
clearTimeout(retryT);
const url = ses.live_url + (failed >= 2 ? (ses.live_url.includes("?") ? "&" : "?") + "r=1" : ""); // D18
let sock;
try { sock = new WebSocket(url); } catch (_) { // malformed live_url: stop cleanly, never throw (rule 18)
teardown("Live: off"); banner("Sparka sent an unexpected reply. Live features may not work.");
return;
}
ws = sock;
sock.onopen = () => {
send({ type: "hello", control_token: ses.control_token, client: "deck" }); // token in first message, never the URL (02 §2.2)
clearInterval(pingT);
pingT = setInterval(() => send({ type: "ping", t: Date.now() }), 25000);
};
sock.onmessage = (ev) => {
if (ws !== sock) return; // superseded or torn down
let m; try { m = JSON.parse(ev.data); } catch (_) { return; }
onMessage(m);
};
sock.onclose = (ev) => {
if (ws !== sock) return; // superseded or torn down
clearInterval(pingT); clearTimeout(stableT); ws = null;
if (!ses) return;
if (STOP.includes(ev.code)) {
const resumable = ev.code === 4001; // token no longer valid: offer Resume, never auto-create (D6)
teardown("Live: off");
if (resumable) { btnStart.textContent = "Resume live session"; banner("Live connection lost. Press Resume to continue."); }
else if (ev.code === 4100) banner("The live session has ended. Press Start to begin a new one.");
else banner("Live features stopped (code " + ev.code + ").");
return;
}
if (sock.welcomed) failed = 0; else failed++; // consecutive attempts without a welcome (02 §8.1)
let delay;
if (ev.code === 4010 || ev.code === 4013) { // moved / draining: retry almost at once, then from n = 1
delay = Math.random() * 250; tries = 1;
} else {
const step = Math.min(10000, 500 * 2 ** tries++);
delay = step / 2 + Math.random() * step / 2; // equal jitter
}
status("Reconnecting…");
retryT = setTimeout(connect, delay);
};
}
function onMessage(m) {
if (typeof m.server_now === "number") offset = m.server_now - Date.now();
switch (m.type) {
case "welcome": // authenticated: full snapshot (02 §4.3)
ws.welcomed = true; failed = 0;
for (const id in revealed) delete revealed[id]; // snapshot replaces local state
status("Live · PIN " + ses.pin);
clearTimeout(stableT); stableT = setTimeout(() => { tries = 0; }, 10000); // reset backoff after 10 s stable
if (m.current) onQuestion(m.current, m.leaderboard);
if (m.counts) onCounts(m.counts.question_id, m.counts);
send({ type: "set_current", question_id: current }); // re-assert after welcome
break;
case "question": if (m.question) onQuestion(m.question); break;
case "counts": onCounts(m.question_id, m); break;
case "timer": endsAt[m.question_id] = m.ends_at; break;
case "revealed": renderReveal(m.question_id, m); break;
case "session_ended": teardown("Live: ended"); break;
case "error": if (m.message) banner(m.message); break;
}
}
function onQuestion(q, leaderboard) {
if (q.timer) {
endsAt[q.id] = q.timer.ends_at;
if (typeof q.timer.server_now === "number") offset = q.timer.server_now - Date.now();
}
if (q.results) renderReveal(q.id, { results: q.results, leaderboard }); // re-shown revealed question
}
function onCounts(id, m) {
if (!revealed[id]) renderCounts(id, m); // a late `counts` must not wipe revealed results
}
// ---- rendering (rules 12-15) ----
function renderCounts(id, m) {
const slide = slideFor(id), q = serverQ.get(id);
const box = slide && $(".sparka-live", slide);
if (!box || !q) return;
box.textContent = "";
if (q.type === "word_cloud") {
const words = (m.terms || []).slice(0, 40); // [{term, count}] (02 §5.5)
const max = Math.max(1, ...words.map((w) => w.count));
for (const w of words) {
const span = document.createElement("span");
span.className = "sparka-word";
span.textContent = w.term; // untrusted participant text
span.style.fontSize = (1 + (2 * w.count) / max).toFixed(2) + "em";
box.append(span);
}
} else {
const byId = m.options || {}; // {a: n, b: n, …}; absent for a quiz until reveal
const total = Object.values(byId).reduce((a, b) => a + b, 0);
(q.options || []).forEach((o, i) => {
const id = LETTERS[i], n = byId[id] || 0, pct = total ? Math.round((100 * n) / total) : 0;
const row = document.createElement("div"); row.className = "sparka-bar-row";
if (m.correct && m.correct.includes(id)) row.classList.add("sparka-correct");
const [L, shape, bg, ink] = TILES[i]; // D21 tile: same letter/shape/color as phones
const tile = document.createElement("b");
tile.textContent = (shape ? shape + " " : "") + L;
tile.style.cssText = "background:" + bg + ";color:" + ink + ";padding:2px 8px;border-radius:4px";
const name = document.createElement("span"); name.textContent = label(o);
const fill = document.createElement("div"); fill.className = "sparka-bar-fill";
fill.style.width = (m.options ? Math.max(2, pct * 0.6) : 0) + "%";
const num = document.createElement("span"); num.textContent = m.options ? n + " (" + pct + "%)" : "";
row.append(tile, name, fill, num); box.append(row);
});
}
const t = $(".sparka-total", slide);
if (t && typeof m.answered === "number") t.textContent = m.answered + " answered";
}
function renderReveal(id, m) {
const r = m.results || {}; // presenter `revealed` (02 §4.3)
revealed[id] = true;
renderCounts(id, { options: r.options, correct: r.correct, terms: r.terms, answered: r.answered });
const ol = $(".sparka-leaderboard", slideFor(id) || document);
if (!ol || !m.leaderboard) return;
ol.textContent = "";
for (const p of Array.isArray(m.leaderboard.top) ? m.leaderboard.top : []) { // already leaderboard_size rows (01 §3.1)
const li = document.createElement("li");
li.textContent = p.rank + ". " + p.nickname + " — " + p.score; // untrusted nickname
ol.append(li);
}
}
setInterval(() => { // countdown (rule 13)
if (!current || !endsAt[current]) return;
const el = $(".sparka-timer", slideFor(current));
if (!el) return;
const left = Math.max(0, Math.ceil((endsAt[current] - (Date.now() + offset)) / 1000));
el.textContent = left > 0 ? left + "s" : "Time's up";
}, 250);
// ---- slide hook + controls (rules 9-11) ----
window.sparkaSlideChanged = (slideEl) => {
const qid = (slideEl && slideEl.dataset.question) || null;
if (qid === current) return;
current = qid;
if (ses) send({ type: "set_current", question_id: current });
};
btnStart.addEventListener("click", start);
btnEnd.addEventListener("click", end);
$$(".sparka-reveal").forEach((b) => b.addEventListener("click", () => {
if (ses && current) send({ type: "reveal", question_id: current });
}));
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return; // Cmd/Ctrl+R is reload, not Reveal
if (e.target.closest && e.target.closest("button, input, textarea, select")) return;
if ((e.key === "r" || e.key === "R") && ses && current) send({ type: "reveal", question_id: current });
});
document.addEventListener("visibilitychange", () => { // back to the tab: reconnect now (02 §8.1)
if (!document.hidden && ses && !ws) connect();
});
if (marked()) btnStart.textContent = "Resume live session"; // never auto-start (rule 6)
verify();
})();
<!-- sparka:/include -->
Notes for the model (part of the guide text):
- Restyle freely (colors, layout, chart style, where the bar sits). Do not
change: Start/End/Reveal being explicit,
set_currenton slide change, reconnect + ping,textContentfor participant text, the questions block. - If the deck uses a slide framework (reveal.js-style events, scroll
snapping), call
window.sparkaSlideChanged(currentSlideElement)from its "slide changed" event. With scroll-based decks, call it from anIntersectionObserverthat picks the single most-visible slide — this selects the live question; it never starts a session. - Fetch-style deck: put only
{"presentation_id":"pr_…"}in the block and write the prompt into each question slide fromserverQafterverify(). <!-- sparka:/part -->
<!-- sparka:part rules -->
3.4 Pitfalls the guide calls out
| Pitfall | Correct |
|---|---|
Calling POST …/sessions on load or when the join slide becomes visible |
Only from the Start button |
Calling sparka.io/mcp or using spk_live_… from the deck |
Deck uses only /v1/presentations/{id}, /v1/presentations/{id}/sessions, and live_url |
Embedding correct in the questions block |
Paste embed_block verbatim; answers are server-side |
| Opening a new WebSocket on every slide change | One connection per session; slide changes send messages |
| Building the WebSocket URL by hand | Use live_url from the response unchanged (only the server knows its exact form) |
Local countdown from time_limit_s |
ends_at + server_now |
innerHTML with words or nicknames |
textContent |
| Blocking modal when verification fails or Sparka is down | Non-blocking banner; slides keep working |
| Retrying on a fixed 1 s loop forever | Jittered exponential backoff (0.5→10 s), r=1 after 2 failures, stopped on "stop" close codes |
Relying on a set_current sent before welcome |
It is queued (≤ 8 frames, 02 §2.2), but always re-assert the current slide's question after each welcome |
| <!-- sparka:/part --> |