sparka home

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

  1. One questions block. Paste the embed_block from sparka_presentation_save exactly 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 from GET https://sparka.io/v1/presentations/{id}.
  2. Never put the secret key in the deck. Nothing starting with spk_live_ ever appears in HTML/JS. The deck uses only the presentation_id, public endpoints, and the control_token it receives at Start.
  3. Question slides carry data-question="<question id>" on the slide element. Exactly one slide per question ID. Inside, put an element with class sparka-live where counts/results render, and a Reveal button (class="sparka-reveal").
  4. Join slide. The title slide (or a dedicated slide right after it) has data-sparka-join and 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_current on slide change, reconnect + ping, textContent for 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 an IntersectionObserver that 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 from serverQ after verify(). <!-- 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 -->