1. Home
  2. Guides
  3. Coded decks

Coded decks

Updated ยท Markdown

Already have a deck written in HTML? Or want your AI to write one? Upload it, and it becomes a live deck: it follows your arrow keys, phones follow it, and it can draw the room's answers in its own style.

The convention

One HTML file. One element per slide, in order, with data-slide.

<section data-slide data-title="Hello">
  <h1>Hello</h1>
</section>

<section data-slide data-id="poll"
         data-ask='{"id":"poll","type":"choice","prompt":"Tea or coffee?","options":["Tea","Coffee"]}'>
  <h2>Tea or coffee?</h2>
  <div data-results="poll"></div>
  <p data-step="1">One more thing.</p>
  <aside class="notes">Wait for the bars to settle.</aside>
</section>

That is all. Upload it from the app (drop it on the page) and it works.

A complete example that uses every convention: the example deck. Open it, view the source, or save it and drop it into the app.

Per slide

AttributeDoes
data-titleThe slide's name. Otherwise its first h1 or h2.
data-notesSpeaker notes. Or an <aside class="notes"> inside.
data-idA stable identity. Upload a new version and your settings stay.
data-stepsHow many extra beats. Or mark elements data-step="1", "2"...
data-askA question, as JSON. Phones are asked it on this slide. Give it an id to show its answers elsewhere.
data-phoneAny other phone view, as JSON.

Elements with data-step light up one per arrow press, with the house "illumination" fade. Restyle .is-lit to change that.

Live answers in your own design

<b data-live="poll.n"></b> answered
<b data-live="people"></b> in the room
<div data-results="poll"></div>

data-live takes n, avg, median, total, top or people. data-results draws plain bars: style .stoa-row, .stoa-fill, .stoa-label, .stoa-n, .stoa-lead.

Or, in script:

<script>
  Stage.on('data', (d) => {
    const poll = d.aggs.poll;           // { n, counts: { o1: 12, o2: 7 }, total }
    const room = d.people;
  });
  Stage.on('goto', ({ index, step }) => { /* your own transitions */ });
</script>

What each question sends

d.aggs has one entry per question id, the same the big screen draws. d.interactions[id] is the question itself: type, prompt, options, view. d.people is how many have joined. A coded deck hears this on the big screen. A custom slide hears it on the big screen and in the console, the same in both. The editor's preview has no live answers.

Questiond.aggs[id]
choice{ n, counts: { optionId: number }, total }. n people, total picks.
scale{ n, avg, dist: { value: number }, min, max, width }. A wide scale counts in buckets width wide.
text{ n, words: [[word, count]], items: [{ id, text, at }] }. Most said first; the latest items.
qa{ n, items: [{ id, text, votes, answered, at }] }. Unanswered first, then by votes.
number{ n, avg, median, min, max, hist: [{ from, to, n }] }. With correct: also correct and closest: [{ name, value, off }].
photo, audio{ n, items: [{ id, url, at }] }
form{ n } only. What people filled in stays with you.
custom{ n, latest }: the latest 50 values sent with Room.send.
@leaderboard{ n, rows: [{ name, score }] }: the top quiz scores.

Only approved answers arrive. A filtered answer, a custom one too, arrives once you show it. Names arrive only where the room shows them: the leaderboard, the closest guesses, and a Q&A with "show who asked" on (name on each item). A name the room keeps hidden reads "Guest 12". Draw the empty case too: n can be 0.

The library kit: three.js, GSAP and more

Your HTML can import a set of animation and graphics libraries, bundled and served by Joocy itself, so a venue that blocks CDNs still runs them. Import them by name:

<script type="module">
  import * as THREE from 'three';
  import { gsap } from 'gsap';
  import confetti from 'canvas-confetti';
</script>

In the kit:

  • three, three/webgpu, three/tsl, and every three.js add-on by its path, as in three's own examples: three/addons/controls/OrbitControls.js, three/addons/loaders/GLTFLoader.js (and DRACO), three/addons/postprocessing/EffectComposer.js, UnrealBloomPass, Water, Sky and the rest.
  • gsap, with every plugin: gsap/ScrollTrigger, gsap/Flip, gsap/SplitText, gsap/MorphSVGPlugin, gsap/DrawSVGPlugin, gsap/MotionPathPlugin, gsap/Draggable, gsap/CustomEase ... or all at once from gsap/all.
  • motion (the engine behind Framer Motion), and motion/react (also as framer-motion) for <motion.div>, AnimatePresence and layout animations.
  • react, react-dom, react-dom/client, react/jsx-runtime, and htm/react to write JSX without a build step: one copy of React for all of them.
  • animejs, d3, pixi.js, p5, lottie-web, matter-js, canvas-confetti, chart.js, simplex-noise, tone.

The always current list, with versions, is in the catalog under kit. Each library loads only when a deck imports it.

An import map is added when Joocy serves your file, so the bare names resolve (a map of your own is merged in, and wins). On the big screen you may also load other libraries from a CDN. On phones you may not (see the sealed tier below): use the kit, or add the file to your deck. Either way, phones and the big screen download the kit once and keep it, slide after slide.

Two places for your own code

Besides a whole coded deck, a single slide of a normal deck can be your HTML, and so can a single phone screen:

  • A custom stage slide (the big screen): in the editor, add a slide and pick Custom (HTML) under Show. Or for an agent, "stage": { "type": "custom", "html": "..." }.
  • A custom phone screen (every phone): on a slide, under Phones, pick Your own (HTML). Or "phone": { "type": "custom", "html": "...", "interaction": "pick", "fill": true }, where the optional interaction is a question of type custom ("pick": { "type": "custom" }) that keeps what Room.send sends. A screen can also be a page from an uploaded folder: "source": "src_x", "src": "screen.html", so it can load models, textures and sound by relative path.

Both run in a sandbox with no origin of their own, and the kit's import map is added, so three.js, GSAP and the rest work the same there.

The Stage bridge (custom slides and coded decks)

Stage exists inside the app; opened on its own, it is simply absent, so a deck still works in a plain browser. On top of what is above:

Stage.on('step', ({ step, dir, instant }) => { /* dir is 1 or -1; instant: a jump */ });
Stage.on('in',  () => { /* this deck or slide is on screen */ });
Stage.on('out', () => { /* it left: pause your loop */ });
Stage.on('progress', ({ value }) => { /* 0 to 1 through the deck, moving smoothly */ });
Stage.on('signal', ({ name, data, from }) => { /* a live cue from a phone */ });
Stage.on('reaction', (counts) => { /* the reaction taps */ });
Stage.broadcast('burst', { color: '#f0a' });   // a live cue to every phone
Stage.timeline(tl, 'deck');                     // a GSAP timeline, played by the beats
Stage.transition(async ({ fromEl, toEl, dir }) => { /* move between pages yourself */ });
Stage.motion('zoom', fromEl, toEl);             // play any transition between two elements

Beats build the same as a coded deck: put data-step="1", data-step="2" on elements (a custom slide counts them for you), and add data-anim="rise|fall|left|right|zoom|pop|fade|blur" for a named entrance. The first beat's pieces arrive with their entrance when the page does.

Timelines. Stage.timeline(tl, which) plays a GSAP timeline by the beats: one press forward plays on to the next stop, back plays back, a jump goes straight there. which is 'deck' (every beat of every page is a stop: one timeline for a whole world), one page (its number, its data-id, or an element in it), or nothing (the only page, else the deck). Stops are the timeline's labels ("2" for a page's beat 2; "3.1" or "<data-id>.1" across a deck), or spread evenly when it has none.

Progress. Stage.on('progress') gives where the talk is through the deck, 0 to 1, and moves smoothly from beat to beat. Tie a camera to a path with it and every press flies there.

Moving between pages

A coded deck's pages move with the same transitions as the app's slides:

  • data-transition="zoom" on a page, for how it arrives. Any built-in name (fade, slide, rise, zoom, blur, iris, wipe, curtain, flip, spin, drop, glide, morph, none), one of the deck's own designs, or a design written in place as JSON: data-transition='{"base":"slide","duration":600}'.
  • <html data-transition="..."> for the whole deck.
  • What you set on the slide in the app wins over both.
  • Magic move: give a piece the same data-morph="name" on two pages and it glides from one place, size and colour to the other while the rest dissolves. No code.
  • Anything at all: Stage.transition(fn) hands you both pages (on screen together, the old one held where it was) and waits for you, up to 8 seconds. Use GSAP, three.js, your own canvas. Return false to let the bridge play its own.

The magic move example shows the first two.

A deck that is one world

A three.js scene the camera flies through, one long canvas: there are no pages to show and hide, so list the slides instead, and draw each one yourself:

<script type="application/json" id="joocy-slides">
[
  { "id": "arrive", "title": "Arrive", "notes": "Let the stars settle." },
  { "id": "field", "title": "A field of light", "steps": 2 },
  { "id": "vote", "title": "Which world next?",
    "ask": { "id": "world", "type": "choice", "prompt": "Which world next?", "options": ["Ice", "Fire", "Ocean"] } }
]
</script>
<script type="module">
  import * as THREE from 'three';
  // ... the scene ...
  Stage.on('progress', ({ value }) => camera.position.copy(path.getPointAt(value)));
  Stage.on('goto', ({ key, step }) => { /* captions, the second beat of "field" ... */ });
</script>

Each entry can carry title, steps, notes, ask, phone and thumb, like a page's attributes. Joocy reads the list without running anything, so your AI can upload a world in one call. The world example is a whole one: a camera path between five places, phones lighting stars in it, a question's answers growing as pillars of light.

The big screen around your deck

  • The join badge sits in the bottom left corner on slides that ask something: about a fifth of the width, a sixth of the height. Keep that corner calm. (Room settings: "Join code in the corner".)
  • Reactions rise up the right edge, across 22% of the width. Keep words people must read out of that strip.
  • Short notices ("Answers locked", "Reconnecting") flash at the top centre.
  • The mouse is the show's, unless you turn it on. Out of the box, nothing of the mouse reaches a coded slide, and a click on the screen's left or right edge moves the show. Turn on "Coded slides take the mouse" in the room settings ("settings": { "mouse": true }), and clicks, hovers, drags, a trackpad and a pen all reach your deck, the edges too. Any coded slide can say otherwise under Screen, "Takes the mouse" (stage.mouse).
  • A link replaces your deck. With the mouse on, a plain link opens inside your deck's frame, and that page stays on the big screen until it reloads. It hears no answers and gets no pointer or keys. A new window (target="_blank", window.open) or a download does nothing. Put links on phones instead (Room.open, or a card's button).
  • Keys reach your deck once someone clicks into it, so only with the mouse on. The bridge still hands the presenter's keys to the show: arrows, Space, Enter, Page Up, Page Down, Backspace, Home, End, Escape, and B . J R L N O F M C ?. Every other key is yours: digits, the other letters, and keys with Ctrl, Cmd or Alt. Leave G and H alone too: on the big screen they open all slides and the keys help.
  • What those keys do: R reveals results, J shows the big join code, L locks answers, N shows notes, B goes black, O shows all slides, F full screen, M sound, C the console.
  • Stickers are pictures you can use: /stickers/<name>.big.webp (320 px, animated, for the big screen), /stickers/<name>.webp (128 px) and /stickers/<name>.still.webp. The names are in the catalog under glyphs.

Trying a deck on your own computer

Add these three lines to its <head>, and it runs in any browser, the arrow keys, the library kit and the transitions included. Inside the app they are already there, and these do nothing.

<script src="https://YOUR-SITE/kit/map.js"></script>
<script src="https://YOUR-SITE/kit/motion.js"></script>
<script src="https://YOUR-SITE/kit/bridge.js"></script>

Opened on its own, no answers arrive: try your drawing code on sample answers in the shapes above. Without these scripts there is no Stage at all, so guard it: window.Stage && Stage.on(...).

Testing on the real big screen: its link opens on a Start button, because browsers play sound only after a click. A browser test must click Start first. Join from a phone to see live answers and Room.me arrive.

The Room bridge (phone screens)

A custom phone screen talks through Room:

Room.send({ pick: 'a' });              // save an answer into the slide's question
Room.emit('spark', { tag: Room.me.tag }); // a live cue to the big screen
Room.on('signal', ({ name, data }) => { /* a cue from the big screen */ });
Room.on('data', (agg) => { /* the room's answers, when phones may see them */ });
Room.on('state', ({ me }) => { /* the beat moved, or Room.me changed */ });
Room.vibrate(12);                      // a short buzz
Room.open('https://...');              // offer a link; the phone confirms it first
hello.textContent = `Hi, ${Room.me.name || 'friend'}`;

An answer sent with Room.send that has a bad word in it, anywhere, keys too, is filtered: it waits for your OK, like a filtered word on a wall. The phone is told it was sent. It shows in the console as "Filtered: needs your OK", and reaches the big screen and other phones once you show it. The question's own switch, "Filter bad words", is under the phone screen in the editor.

Room.me.tag is a short stable id for this person, the same one the big screen sees on their signals, so a deck can give each phone its own colour.

Room.me.name is their own name, as the room shows it. It is empty until they give one, and while the room keeps it hidden. A screen never hears anybody else's name.

Room.on('data') gives the slide's question in the shapes above, with fewer items, when phones may see results. Room.theme and the CSS variables var(--accent), var(--ink), var(--display) carry the deck's look.

A tap is only ever a tap: a quick second tap never zooms the phone, so a screen can be one big button people hit as fast as they like. Pinching still zooms. An element with a drag of its own, like a drawing pad, sets touch-action: none on itself.

The sealed tier: what phone screens may reach

A phone screen is other people's device, so it is sealed. It may load the kit, your deck's own files and a font service, and nothing else: no fetch, picture, socket or form reaches another site, and it cannot leave the page or read the page around it. So whatever it draws, anything typed into it can go only to the room, through Room.send and Room.emit. Joocy refuses, and never shows, a phone screen that asks for a password, a card number or a one-time code, and refuses any answer that is a card number. Do not ask a room for secrets; it will not work.

So on a phone screen there are no downloads, no popups, no dialogs and no localStorage. To offer a link, use Room.open. To let people keep a picture, show it as an <img>: they long-press it to save it.

A custom stage slide on the big screen (your own machine) is not sealed: load any library from any CDN there.

Transitions between slides

Decks change slides with a move you choose: fade (the default), none (a cut), slide (a push), rise, zoom, blur, iris, wipe, curtain, flip, spin, drop, glide, or morph (magic move: the title, the number, the picture glide into their new places). Set a default for the deck under Room, or give any slide its own under Screen, with a duration, and press Play it to see it.

You, or your AI, can also design transitions of your own: what the leaving and arriving slides do, which pieces arrive one by one, what glides, how phones arrive. They are data, checked when saved, so anything can be designed and nothing in one can run code or load anything. The deck format has the details; your AI reads them in the guide, and its get_transitions tool hands it every built-in to start from.

Live signals, during a talk

A phone screen's Room.emit(name, data) reaches a custom slide or coded deck on the big screen as Stage.on('signal'): taps, drags, a colour, a game move. The big screen's Stage.broadcast(name, data) reaches every phone screen as Room.on('signal'). Both are kept nowhere and are rate limited; a phone's signals are word filtered, so the audience cannot push a bad word onto the screen. See the showcase deck for a 3D scene the room's taps light up.

Keynote's HTML export

In Keynote: File, Export To, HTML. Drop the folder (or a zip of it). Joocy recognises it and plays it with Apple's own player, every build and transition as designed. Titles come from each slide's largest words, one step per click, and Keynote's picture of each slide becomes its thumbnail. Upload a new export later and your questions and notes stay on their slides.

Joocy drives Apple's player directly. Next plays the next build or transition exactly as designed. Back, a thumbnail or a jump fades for a moment and lands on the whole slide: Apple's player draws a slide whole only by playing into it, so Joocy plays that lead in, very fast, out of sight. A thumbnail lands at the start of its slide, so its builds can play again. Presses that arrive while it animates are kept, never lost or doubled.

An agent can do the same: upload every file with its path, then apply with "scan": true.

Other shapes that just work

  • section.slide, or plain sections under body or main.
  • reveal.js decks, as they are.
  • A deck that drives itself in script can say so:
<script>
  window.StageDeck = {
    slides: () => SLIDES.map((s) => ({ title: s.name, notes: s.note, steps: s.beats })),
    goto: (index, step) => showSlide(index, step),
  };
</script>

The app runs it once in a hidden sandbox to read its slides.

Many files

Put the HTML, styles, scripts, fonts and pictures in one folder, with the HTML named index.html. Zip the folder. Drop the zip. Relative links between the files work as they did on your computer.

Uploading a new version

Editor, select one of its slides, Screen, Upload a new version. Slides you set up (questions, notes, phone views) keep everything. Pages that are gone are removed. New pages appear in place. Native slides you added between its pages stay where they were.

That cuts both ways. A slide that already has a phone view, notes or a title keeps them, even if the new file says something else in data-phone, data-notes or its slide list. A page's question changes only when the page's own question does. To change those from code, write them into the deck after uploading: your AI patches each slide's phone, phoneByStep and notes.

Your AI can do the same in two calls; see the AI guide.

How it is kept safe

Your HTML runs in a sandbox with no address of its own. It cannot read anybody's sign-in, cannot call the app's API as you, and cannot touch the page around it. It talks to the app only through the small bridge script, which the app adds when it serves the file. You never need to add it.

On the big screen (your own machine) that sandbox is open inside: load any library, reach any site. It opens no new windows and saves no files. A coded slide takes the mouse and pen only if you turn that on, and the app's own controls stay above it, out of its reach. A phone screen is sealed tighter, because it runs on other people's devices: it may reach only the library kit, your deck's own files and a font service, so anything typed into it can go nowhere but the room. Either way, the app's own pages are untouched, and a page opened on its own address runs nothing and only explains itself.

Rules of thumb

  • Size things with viewport units or percentages: the stage is any size.
  • Do not rely on localStorage; the sandbox has none.
  • Arrow keys and the presenter's keys belong to the presenter. The bridge passes them up (see The big screen around your deck).
  • Keep the bottom left corner and the right edge calm: the join badge and the reactions live there.
  • Check it in a browser first: opened on its own, the arrow keys still work.

All guidesJoocy home