widgets

Widgets for your community

A widget is a tiny live app that sits inside a chat message. People in the room can watch it, join it, and play with it without leaving the conversation. Here's how you can build & ship your own.

What a widget is

Think of a widget as a board, poll, toy, or mini-game that travels with a message. When someone posts one, the room sees a compact card under that message. Clicks and moves go through Divcord. The server checks the rules, then every client redraws the same state.

That split is the whole design:

  • The card is just a view. It draws buttons and a board.
  • The engine is the source of truth. It decides whether a move is legal and what the next state looks like.
  • The message is the save file. Reload the room later and the widget is still there, in whatever state it reached.

Using widgets in a room

  1. Open a room you can talk in.
  2. Click the puzzle-piece button next to the message box, or type /widget.
  3. Browse the catalog. Search and tags filter the cards.
  4. Click a card. Divcord posts a message with that widget attached.

New servers start with widgets off. Server admins can turn them on or pick a subset from the Widgets tab on the dashboard.

You already occupy the first seat. Anyone else in the room can tap Join on the card. People who do not join can still watch. New arrivals who scroll back through history see the current board, not a dead screenshot.

In a hurry, skip the browser: /widget tictactoe posts that widget immediately.

Same room rules as chat. If you are muted, banned, or the room is locked, you cannot start or play a widget there. Deleting the message removes the widget with it.

What widgets can do

A widget is small on purpose, but the loop is enough for real multiplayer toys.

  • Live shared state. One object on the server. Every client paints from that object.
  • Turn-taking or free-for-all. Your engine decides whose click counts.
  • 1 to 16 seats. Solitaire, duels, or a small group activity.
  • Spectators for free. Anyone who can see the message sees the card update.
  • Custom UI inside the card. Buttons, boards, meters, text. Style it with a CSS file.
  • A finish line. Mark the session finished and attach a result (winner, draw, score).
  • History that survives refresh. The widget rides on the message, so it comes back with the room.
  • Bots can play too. A bot account can start a widget and send actions the same way a person does.

Good fits:

  • Short board games and puzzles
  • Coin flips, dice, rock-paper-scissors
  • Tiny reaction or tapping races
  • Room polls and score keepers
  • Turn-based toys that take under a minute

What widgets cannot do

Widgets live inside a chat bubble. They are not full apps and they do not get a private server of their own.

  • No secret server logic beyond the engine. engine.js is a reducer. It cannot talk to the internet, read files, query the database, or reach other rooms.
  • No extra Node packages. Keep the engine in plain JavaScript. If you need a library, you cannot add one here.
  • State must be JSON. Numbers, strings, arrays, plain objects. No functions, no Dates, no Maps.
  • State cap is 16 KB. A 3x3 board is fine. A replay of every pixel is not.
  • Each action is 2 KB and the type name is a short token like move or tap.
  • Actions are rate limited. A widget is not a 60 fps game loop. Think clicks and turns, not streaming input.
  • The card is narrow. Design for about 280px wide so it fits a message on desktop and mobile.
  • The client cannot cheat-proof itself. Hide nothing important only in client.js. If the engine does not reject a bad action, the action stands.
  • Old messages get pruned. Room history is capped. When the host message ages out of history, the widget goes with it.
  • A widget is not installed until the host process restarts. Dropping a folder on disk does not hot-reload yet.
  • No cross-room or cross-server sessions. Each post is its own instance, scoped to that room.

How a widget is built

Every widget has its own folder:

widgets/
  your-widget/
    widget.json    catalog card (required)
    engine.js      rules (required)
    client.js      UI (required)
    style.css      looks (optional)

Divcord reads that folder on startup. The catalog card in the widget browser comes from widget.json. Clicks in the room hit engine.js. What people see is client.js, served at /widgets/your-widget/client.js.

engine.js is never sent to browsers. Put the rules there. The client should only call dispatch and paint.

widget.json

{
  "id": "tap5",
  "name": "First to 5",
  "version": 1,
  "minPlayers": 2,
  "maxPlayers": 8,
  "icon": "fa-solid fa-bolt",
  "description": "Everyone taps. First person to 5 taps wins.",
  "tags": ["party", "reflex"]
}
  • id is the folder name and the slash command. Use a-z, digits, _, -. 1 to 32 characters, must start with a letter or number.
  • icon is a Font Awesome class. It shows in the widget browser. Default is the puzzle piece.
  • minPlayers / maxPlayers are 1 to 64. The person who posts the widget already fills seat one. Set maxPlayers to null or false for no seat cap; the browser then hides the player count.
  • tags become filter chips in the browser. Keep them short.
  • hideRoster hides the Players line on the chat card. Use it for room toys that are not seated games.

The engine contract

engine.js is CommonJS and exports two functions.

function init(ctx) { /* ... */ }
function apply(state, action, ctx) { /* ... */ }
module.exports = { init, apply };

init runs once when the widget is posted. It receives:

  • host — username of the person who posted it
  • players — starts as [host]
  • now — timestamp
  • minPlayers, maxPlayers

Return either a state object, or:

{ state, status: 'waiting', result: null, secret: null }

apply runs on every action. It receives the current state, the action, and:

  • username — who clicked
  • host, players, status, now
  • secret — server-only blob from the last init/apply. Clients never see it.

Return one of:

{ ok: true, state, status, result, secret }
{ ok: false, error: 'Not your turn' }

Put answers, decks, or roles in secret, not state. Optional publicState(state) can strip leftover keys from old sessions. Keys that start with _ are also stripped before a session is sent to browsers. Optional personalState(state, secret, username) is sent only to that user on their own action callback as private (also on api.private()). Use it for the drawer's word, a private hand, and similar. Do not put that data in state.

status is waiting, active, or finished. The host framework already handles seating: join is rejected when the widget is full, and the actor is added to players after a successful join. Your engine still has to record that person in state if you care who is who.

The client contract

client.js runs in the page. Register a mount function. Do not open your own socket.

DivcordWidgets.register('your-id', {
  mount(root, api) {
    function paint() {
      const session = api.session();
      const me = api.me();
      api.setStatus('Waiting for players');
      // session.state, session.players, session.status, session.result
    }
    paint();
    return { update: paint, destroy() {} };
  }
});
  • api.session() is the latest server state for this message.
  • api.me() is the signed-in username, or null.
  • api.setStatus(text) writes the grey line above the board.
  • api.dispatch({ type: 'tap' }) sends an action. The server runs apply.
  • api.join() is dispatch({ type: 'join' }).
  • update is called whenever the room gets a new state. Redraw from api.session(). Do not keep a private copy that can drift.
  • destroy runs if the message is removed or redrawn.

Build one from scratch

This walk-through makes First to 5: two or more people join, then tap a button. First to five taps wins. Copy the four files below into widgets/tap5/ on the server, restart Divcord, then post it with /widget tap5.

1. Name it

Create the folder and the catalog card.

widgets/tap5/widget.json
{
  "id": "tap5",
  "name": "First to 5",
  "version": 1,
  "minPlayers": 2,
  "maxPlayers": 8,
  "icon": "fa-solid fa-bolt",
  "description": "Everyone taps. First person to 5 taps wins.",
  "tags": ["party", "reflex"]
}

2. Write the rules

The engine stores a score map. join seats a player at zero. tap adds one point for that username. First to five finishes the widget.

widgets/tap5/engine.js
function init({ host }) {
  return {
    state: {
      scores: { [host]: 0 },
      goal: 5
    },
    status: 'waiting',
    result: null
  };
}

function apply(state, action, ctx) {
  if (action.type === 'join') {
    if (state.scores[ctx.username] != null) {
      return { ok: false, error: 'You already joined' };
    }
    return {
      ok: true,
      state: {
        ...state,
        scores: { ...state.scores, [ctx.username]: 0 }
      },
      status: ctx.players.length + 1 >= ctx.minPlayers ? 'active' : 'waiting'
    };
  }

  if (action.type !== 'tap') {
    return { ok: false, error: 'Unknown action' };
  }
  if (ctx.status === 'finished') {
    return { ok: false, error: 'Already finished' };
  }
  if (ctx.status !== 'active') {
    return { ok: false, error: 'Wait for more players' };
  }
  if (state.scores[ctx.username] == null) {
    return { ok: false, error: 'Join first' };
  }

  const next = state.scores[ctx.username] + 1;
  const scores = { ...state.scores, [ctx.username]: next };
  if (next >= state.goal) {
    return {
      ok: true,
      state: { ...state, scores },
      status: 'finished',
      result: { winner: ctx.username, reason: 'goal' }
    };
  }
  return {
    ok: true,
    state: { ...state, scores },
    status: 'active'
  };
}

module.exports = { init, apply };
Reject in the engine, not the button. Disabling the Tap button is polite. The engine is what stops a spectator from forging a tap.

3. Draw the card

widgets/tap5/client.js
(function () {
  const widgets = window.DivcordWidgets;
  if (!widgets) return;

  widgets.register('tap5', {
    mount(root, api) {
      const list = document.createElement('div');
      const tap = document.createElement('button');
      tap.type = 'button';
      tap.textContent = 'Tap';
      tap.onclick = function () { api.dispatch({ type: 'tap' }); };
      const join = document.createElement('button');
      join.type = 'button';
      join.textContent = 'Join';
      join.onclick = function () { api.join(); };

      root.appendChild(list);
      root.appendChild(join);
      root.appendChild(tap);

      function paint() {
        const session = api.session();
        const me = api.me();
        const scores = (session.state && session.state.scores) || {};
        const seated = Object.prototype.hasOwnProperty.call(scores, me);

        if (session.status === 'waiting') api.setStatus('Waiting for players');
        else if (session.status === 'finished') {
          const winner = session.result && session.result.winner;
          api.setStatus(winner ? winner + ' wins' : 'Finished');
        } else {
          api.setStatus('First to 5');
        }

        list.textContent = '';
        Object.keys(scores).forEach(function (name) {
          const row = document.createElement('div');
          row.textContent = name + ': ' + scores[name];
          list.appendChild(row);
        });

        join.hidden = !(me && session.status === 'waiting' && !seated);
        tap.disabled = !(me && seated && session.status === 'active');
      }

      paint();
      return { update: paint, destroy: function () {} };
    }
  });
})();

4. Make it look like a card, not a raw form

widgets/tap5/style.css
.tap5-row { font-size: 13px; color: #ddd; margin: 2px 0; }
button { margin-right: 6px; }

Widget CSS is loaded for the whole page, so prefix your classes (tap5-) instead of styling bare button tags. The snippet above is the minimum.

5. Update and try it

  1. If you have access to upload new widgets, upload it now so it picks up widgets/tap5.
  2. Open a room, click the puzzle piece, and look for First to 5.
  3. Post it from account A. Join from account B. Tap until someone hits 5.
  4. Refresh the page. The scores should still be there.

Test in the lab

Do not upload a folder you have not run. Open the widget lab, drop your four files, and press Run engine. The lab boots engine.js in the browser, mounts client.js in a fake message card, and lets you switch seats (Ada, Bob, Cho, Dee, Eve) so you can join and play against yourself. Use Render client and the session snapshot when you only want to paint the card.

Watch the state inspector and the byte counter. If state crosses 16 KB, or an action is rejected, fix that before the widget goes on the server. The lab never uploads your source.

Before you ship

  • Every click that changes the score or board goes through dispatch.
  • apply rejects moves from people who are not seated, and rejects moves after finished.
  • State stays small and JSON-safe. No leftover UI nodes in state.
  • The card still makes sense at phone width.
  • Spectators can read the status line without joining.
  • The widget has an ending. Infinite rooms go stale in history.
  • You tried the happy path, a double-click, a spectator click, and a refresh.
  • You ran the four files in the widget lab as two different seats.

Get it listed

Widgets are stored on our server. Uploads are limited at the moment. If you would like to beta dev games or widgets for divcord please contact Andrew.