Table apps

Write a small page — a puzzle, a console, a vote — and present it to your players: the four templates, how to talk to the table, why the answer can't live in the code, resources, and what doesn't exist inside the box.

A table app is a small page you write yourself (HTML, CSS and JavaScript) and show to your players as another floating window: a puzzle everyone touches at once, a ship console, a star map that reacts, a vote, a countdown.

You don't need to know how to program: there are four templates that work as they are and only need two things changed.


Where it is

Immersion Mode → Show to the table → App tab.

There you have the templates, your saved apps and the Expand button, which opens the editor full screen with highlighting, inline warnings, autocomplete and asset uploading. Use it for anything longer than a few lines.


The short path

  1. Click Rune lock. Everything fills in, including the answer.
  2. Click Test: you see it exactly as they will see it.
  3. Click Present to the table.

It appears as a floating window for every member of the campaign. It can be moved, resized and minimized, and the trash can removes it (for everyone).


How it works underneath, in one sentence

Your code runs in each player's browser, inside an isolated box, so it is safe to run: your app cannot see anyone's session or data, and cannot send anything out.

The trade-off is that some things that work elsewhere don't work here. Each restriction closes a specific security hole. They are all listed in the editor's Help tab, and the linter warns you as you write.

Doesn't existWhat happensInstead
localStorage, sessionStorageThey throw an errorA variable; and roliando.guardar() for what should last
alert, confirm, promptBlockedDraw the message on the page
fetch, XMLHttpRequestNo networkroliando.enviar() to talk to the table
window.openBlockedNothing
import, requireNo modulesPaste what you need

Sound does work, but it doesn't start until someone touches the window. Create or resume your AudioContext inside a click, or call roliando.sonar() from one.


Talking to the table

Inside your app you have a roliando object. It is the only way to ask Roliando for anything:

// ── WHO AND WHAT IS THERE ──────────────────────────────────────────────────
roliando.yo                    // { nombre, esMaster } — who is looking
roliando.assets                // { nombre: url } of what you have uploaded
roliando.jugadores()           // [{ id, nombre, esMaster }] — who is seated RIGHT NOW
roliando.alJugadores(fn)       // fn(list) when someone joins or leaves

// ── WHAT GETS SAVED ────────────────────────────────────────────────────────
roliando.estado                // what was saved when your app started (or null)
roliando.partida()             // what is saved RIGHT NOW (a function, not a value)
roliando.versionPartida()      // its version, in case you are debugging
roliando.guardar(datos)        // make it survive a reload
roliando.alEstado(fn)          // fn(state, who) when the game changes
roliando.alAviso(fn)           // fn(reason) if something could NOT be saved

// ── TALKING LIVE ───────────────────────────────────────────────────────────
roliando.enviar(datos)         // send it to the others, now (ephemeral)
roliando.alRecibir(fn)         // fn(data, who) — the other half of `enviar`

// ── PUZZLES ────────────────────────────────────────────────────────────────
await roliando.comprobar(intento)  // → { ok, intentos, agotado }
await roliando.miParte()           // → { hay, master, mia, partes } — your piece of the split
roliando.alAbrir(fn)               // the GM opened it by hand: the puzzle is over
roliando.progreso()                // who is where (null if you are not running it)
roliando.alProgreso(fn)            // fn(list) when it changes (only if you are running it)

// ── OUTWARD ────────────────────────────────────────────────────────────────
roliando.resuelto('has opened the lock.')       // to the campaign chat
roliando.sonar('gong')                         // through each person's volume
await roliando.tirar('2d6+3', { etiqueta: 'Attack' })  // real 3D dice → { ok, total, dados }

roliando.partida() and roliando.progreso() are functions and not values because a value would have to travel inside the document, and changing the document reloads the frame and restarts your app. This caused a real loop in production.

The sheet of whoever is looking

An app can read a few numbers from the sheet of whoever has it in front of them, and only those it declares beforehand:

// In the app, next to the HTML/CSS/JS: which fields you need.
ficha: ['fuerza', 'pg']
const f = await roliando.miFicha();
// → { hay: true, nombre: 'Kira', campos: { fuerza: 14, pg: 22 }, faltan: [] }
if (f.hay) pintar(f.nombre, f.campos.fuerza);

Four rules, enforced by the engine:

  1. Only your own. Each browser answers with its own; there is no way to ask for someone else's.
  2. Only what is declared. What is not in ficha does not arrive, and what is declared is shown in the window before anyone touches it.
  3. No free text. Only numbers. No background, notes, story, contacts or inventory, since that is what someone might be keeping from their own table.
  4. It never writes. An app does not touch anyone's sheet.

What this does not prevent, and it's worth knowing. The box stops your app from sending anything to the internet, but not from telling the table. If your app reads everyone's Strength and shows it with enviar(), everyone sees it. That's normal in a tabletop game, but it's your call.

If you build it in Creations, tell it which system it's for. There is no table in front of you there, so the Sheet tab asks you to choose between your systems and D&D (or none, if you'd rather fill it in by hand). This doesn't lock it: the app can be presented at any table, and it warns you if its fields belong to another system.

Don't memorize the keys. In the big editor there is a Sheet tab: it shows you the numeric fields of your table's system with their key and label. Pick them with a click and it writes the code for you. The Test with my sheet button shows what miFicha() would return right now, using the real function.

The same app at another table

fuerza and pg are the fields of one system. The same app at a table playing something else does not find them, so the response comes with faltan listing what it couldn't provide:

const f = await roliando.miFicha();
if (f.faltan.length) pedirlosAMano(f.faltan);   // and the app keeps working

When the app is presented, anyone missing a number gets a strip inside the window ("this app needs some numbers your sheet can't give it") to enter them once. They arrive the same way as the values read from the sheet and take precedence over them, so the app can't tell the difference. This lets you build minigames whose numbers are not on any sheet, such as the Strength of the beast a party rides, which belongs to nobody.

Also save which system you wrote it for (sistema), so it warns you before you present it at a different table instead of breaking in front of everyone.

In D&D the keys are fixed

A D&D sheet has no fields that anyone sets, so the vocabulary is always the same:

WhatKeys
Ability scoresstr dex con int wis cha, plus their modifier, str_mod…
Skillsathletics, animal_handling, sleight_of_hand, stealth… (with underscore)
Derivedac hp hp_max initiative speed level prof passive_perception

They come out effective: if you wear a Belt of Giant Strength, str is the belt's value, not the one on the sheet. The editor's Sheet tab lists them just like those of a System Builder system.

One thing worth knowing: in D&D those numbers are read from a copy your sheet saves when it opens (the same one combat uses for AC and saving throws). If the owner hasn't opened the sheet for three levels, that copy is stale. If a number looks wrong, ask them to open their sheet once.

What still can't be reached

Another player's sheet, anyone's free text, and writing to any of them. There is no way to do any of these.

Making everyone see the same thing

enviar and alRecibir are two halves of the same thing. If an app only sends, everyone sees their own version:

// When turning a rune: let the others see it turn
roliando.enviar({ giros: giros });

// And the other way around
roliando.alRecibir(function (datos) {
  if (datos.giros) { giros = datos.giros; pintar(); }
});

enviar is fast and ephemeral, for live interaction. Anything that must survive an F5 goes in guardar, which persists but is less frequent. Usually you call both.

The two faces of an app

The same code receives roliando.yo.esMaster, so players can see the lock while you see the board:

if (roliando.yo.esMaster) {
  roliando.alProgreso(function (lista) {
    // [{ nombre: 'Cerbrick', intentos: 3, resuelto: false }, ...]
    pintarTablero(lista);
  });
}

A puzzle's answer

This is the most important thing in the whole guide. Your code is sent to your players' browsers to run there, so anything written inside it can be read. If the solution is in the code, anyone can find it.

That's why there is a The answer field, bottom right in the big editor. Whatever you put there stays on the server and travels to nobody. Your app can only ask:

roliando.comprobar(giros).then(function (r) {
  if (r.ok) { /* open */ }
  else if (r.agotado) { /* out of attempts */ }
  else { /* failed: r.intentos says how many so far */ }
});

The answer can be a list (["ᛁ","ᚦ","ᚠ","ᛚ"]), a number, an object or a single word: it is compared with what your app sends to comprobar.

The attempt cap

With the answer safe, someone who wants to skip the puzzle can only try combinations. Four runes with six symbols give 1,296, which a loop gets through almost instantly.

The attempts field sets a cap per person. Leave it empty for no cap, which is fine for an atmosphere riddle. The same count feeds your board.


Images and sounds

Use the Resources tab of the big editor. What you upload there are the only addresses your app can load. Any other address is blocked silently (no error, it just doesn't show up). The linter warns you if you write an outside address.

In the code you request them by name:

<img id="mapa">
document.getElementById('mapa').src = roliando.assets.mapa_estelar;
roliando.sonar('gong');

sonar is better than a raw <audio>: it plays outside the app, through the effects volume each player already has set, so it respects their slider and doesn't fight with the voice call.

Rolling real dice

Your app can request a roll that the whole table sees: the 3D dice roll on everyone's screen, the roll goes to the log and the chat, and it shows on the broadcast. Roliando makes the roll, so your code can't decide the result, only receive it.

const r = await roliando.tirar('1d20+5', { etiqueta: 'Perception' });
if (r && r.ok) show(r.total);           // r.dados = each die's value
  • Formula: whatever the dice tray understands (2d6+3, 1d20, 3d6+1d4-1), up to 50 dice.
  • Options: etiqueta (what's being rolled), quien (the name shown; by default, the character played by whoever rolls), tipoDano (fuego, frio… tints the dice) and secreta (only counts when the GM rolls: the table doesn't see it).
  • Response: { ok, total, dados, formula } once the dice stop, or { ok: false, error } if the formula is invalid. If another roll is in progress or you ask too often, you get { ok: false, ocupado: true } (one at a time, at most one every second and a half).

Saving and reusing

Save puts the app in your library with its answer and its cap. It appears in My apps and opens in the editor again with one click.


The four templates

What it isWhat to change
Rune lockFour runes that turn; the combination is checked by the serverThe four indices of the answer
Table voteA question, some options, live tallyThe question and the options, first line of the JS
CountdownThe clock runs for everyone; the GM drives itSEGUNDOS
Shared whiteboardA canvas where everyone draws at onceNothing

The countdown is driven by one person only and the others follow. This is on purpose: with two clocks running at once, shared counts drift apart.


Limits

  • Three apps at a time per table. Each one is a web page with its JavaScript running in everyone's browser, and more than that gets in the way.
  • 72,000 characters between HTML, CSS and JS (24,000 of HTML, 20,000 of CSS and 26,000 of JS), and up to 24 uploaded resources. The whole document travels over the network to the whole table every time it is presented, so the cap matters.
  • Count on one or two tenths of a second of round trip. For puzzles, dials, levers and reveals you won't notice it. For anything that depends on reflexes, it's too slow.
  • Sound plays for everyone, but each browser starts when the message reaches it. For ambience and tension hits that's fine, but not for anything synchronized to the millisecond.
  • In broadcast mode the app is visible, but there it's a snapshot: nobody touches it.
  • In the Waiting room they can't be presented yet.

If something doesn't work

"Nothing shows up." Check the error counter at the top. A syntax error leaves the app mute (the HTML is drawn and nothing else happens), so it just looks broken with no clue why.

"The image doesn't load." Make sure you uploaded it in Resources. Outside addresses are blocked without warning.

"It says no even though I'm right." Check that The answer is set. Without it, comprobar says no to everything.

"Everyone sees something different." You're missing roliando.alRecibir: you're sending but not listening.

"No sound." The browser won't play sound until someone touches the window. Start the audio from a click.

All Roliando guides