Dicey Table

Concepts

Highlights and clicks

Outline entities with named styles, publicly or privately per seat, and hear which entity a player clicked - the pick-a-card loop of a scripted game.

On this page9 sections

A scripted game usually locks its cards and moves them for the players. That keeps the rules in charge, but it takes away the two things a player does with their hands at a real table: pointing ("this one attacks that one") and picking ("I play this"). Highlights and clicks give both back.

  • A highlight is a coloured outline a table script puts on entities with world.setHighlight. It shows state the rules care about that the table cannot show by itself: what is attacking, what is being aimed at, what you could play.
  • A click is a player pressing and releasing an entity without dragging it. The script hears it through globalEvents.onObjectClicked (or refObject.onClicked) and decides what it means.

Together they make the loop most card games need: outline what a player may pick, wait for the click, then act or ask with ui.prompt.

Both are for table scripts only. A mod has no api equivalent of either.

The three built-in styles#

A highlight names a style, not a colour, so a game says what an outline means and the platform draws it the same way everywhere.

Style Look Meaning
target red-orange, steady Being aimed at.
active gold, pulsing Acting now, for example attacking.
playable mint, steady, slightly softer You could play or use this.

They were chosen to stay distinct from each other, from the seat colours and from the selection outline, so a player can read them without a legend. A game can add its own styles, or restyle these three, in the editor; see Styles in the editor.

Outlines work on any entity, face up or face down, on the table, on a card tile in a hand drawer and in VR.

Public and private outlines#

Without options, an outline is public: every player sees it. That is right for facts everyone already knows, such as which unit is attacking which.

With { seats: [...] } it is private to those seats, and this matters for hidden information. "The cards in your hand you can play" says a lot about that hand: drawn publicly, it would tell the opponent what you hold. A private outline travels like a seat's private Game UI state. It is saved with the table, and each seat's outlines reach that seat's browser alone; no other player's browser ever receives them, not even hidden.

One outline per entity per audience. Each entity carries at most one outline from your game publicly and one per seat privately. Setting a style replaces the one you set before in the same audience, so you never clear before you set. On screen each player sees one outline per entity: a private one beats a public one, and among several the most recent wins.

Lifetime#

  • An outline stays until you clear it: world.clearHighlights by style or seat, or world.setHighlight(ids, null) for particular entities.
  • With seconds (0.5 to 600) it removes itself, on the host's clock, so every player sees it go together.
  • Outlines are saved with the table and restored by undo, like the rest of your Game UI.
  • Restarting your scripts drops timed outlines only. Persistent ones stay, so when a script starts, draw the outlines again from your own state, as you would your widgets.
  • An entity that leaves the table (destroyed, drawn, absorbed into a stack) loses its outline.
  • The host resolves a style's look when you call, so changing a style in the editor affects later calls, not outlines already on the table.

Outlines belong to your game's scripts: no other game, Asset pack or mod can change or clear them.

Styles in the editor#

The editor's HIGHLIGHT STYLES section, beside SCENE UI under the scene scripts, lists the three built-ins with a swatch and a Customise button, and lets you add up to 16 styles of your own (restyled built-ins included). Each style has a name scripts use, an editor-only label, a colour, a width, an intensity and a pulse. Styles are scene data, so they follow the top bar's Changes apply to scope: each variant can have its own. See Highlight Styles for every field.

A script that names a style the game does not have is refused, and the diagnostic lists the names the game does have.

What counts as a click#

A click is a press and release of the same pointer on an entity, without dragging it:

Input Counts as a click when
Mouse The pointer moves at most 6 px and is released within 1.5 s.
Touch The finger moves at most 12 px and lifts before the long-press (about 0.45 s) that opens the menu.
VR The trigger is pressed on an entity that cannot be grabbed (for example a locked one), or a grab is a quick, near-motionless tap.

Locked entities are clickable, which is the point: a scripted game locks every card. A tap or click on a card tile in the player's hand drawer counts too.

Clicks are observed beside the table's own pointer handling, never instead of it. Dragging, the right-click or long-press menu and hover tooltips work exactly as before. A double-click is two clicks. A click with Ctrl, Shift, Alt or Meta held is not a click, and neither is one made while a tool (ping, draw, stamp, flick) is active.

How a click reaches your script#

A player's browser sends only "I clicked this entity" to the host, as an object-click intent. The host then:

  1. drops it if the entity is gone, or if that player has already sent 8 clicks in the last second;
  2. looks the sender's seat up in its own roster, so ObjectClickContext.seat can be trusted (it is null for a spectator or a host without a seat);
  3. raises the event for your scripts. Nothing on the table changes.

A click is not refused because it is not that player's turn or not their entity. That is deliberate: in a reaction window the player who must decide is usually not the one whose turn it is. Your handler decides who may click what, by checking context.seat and whether the entity is one you offered.

The reaction-window pattern#

This is the loop a scripted card game uses when one player attacks and another may answer:

  1. Outline the attacker active and what it attacks target, publicly.
  2. Work out which cards the defending seat could play, and outline them playable with { seats: [defender] }.
  3. In onObjectClicked, ignore every click that is not from the defending seat or not on one of those cards.
  4. For a click that is, ask the defender with ui.prompt(seat, { title, actions }): the card's options, plus a way back.
  5. When the window closes (a choice, a pass or a timeout), world.clearHighlights({ style: "playable" }), or clear everything when the attack resolves.
TypeScriptconcepts.highlights-and-clicks.ts
// content/scripting-api/examples/concepts.highlights-and-clicks.ts

// Scene script: an attack with a reaction phase, start to finish.
//
// 1. The attacker and its target get PUBLIC outlines.
// 2. The defending seat's playable cards get a PRIVATE outline.
// 3. A click on one of them asks the defender what to do with it.
// 4. The reaction phase closes after a choice, or after 20 seconds, and the
//    outlines go.

interface Attack {
  attackerId: string;
  targetId: string;
  defender: string;
  playable: Set<string>;
}

let attack: Attack | null = null;

// Your rules decide what a seat may play. Here: every card in hand.
function playableFor(handIds: readonly string[]): string[] {
  return [...handIds];
}

function openAttack(attackerId: string, targetId: string, defender: string, handIds: readonly string[]): void {
  const playable = playableFor(handIds);
  const current: Attack = { attackerId, targetId, defender, playable: new Set(playable) };
  attack = current;
  world.setHighlight(attackerId, "active");
  world.setHighlight(targetId, "target");
  if (playable.length > 0) {
    world.setHighlight(playable, "playable", { seats: [defender] });
  }
  void world.wait(20).then(() => {
    if (attack === current) {
      resolveAttack(null);
    }
  });
}

function resolveAttack(reactionCardId: string | null): void {
  const current = attack;
  if (current === null) {
    return;
  }
  attack = null;
  // Every outline this game set: public, and every seat's private ones.
  world.clearHighlights();
  world.broadcast(
    reactionCardId === null ? `${current.defender} does not react.` : `${current.defender} reacts.`
  );
}

globalEvents.onObjectClicked.add((card, context) => {
  const current = attack;
  if (current === null || context.seat !== current.defender || !current.playable.has(card.id)) {
    return;
  }
  ui.prompt(current.defender, {
    title: card.name ?? "React with this card?",
    actions: [
      { id: "play", label: "Play it" },
      { id: "back", label: "Choose another" }
    ],
    timeoutSeconds: 15
  })
    .then((result) => {
      if (attack === current && result.actionId === "play") {
        resolveAttack(card.id);
      }
    })
    .catch((error: unknown) => world.log(`Could not ask ${current.defender}: ${String(error)}`));
});

void openAttack;

Limits#

  • Table scripts only. A mod has no api call for outlines and no api.on hook for clicks.
  • Not in the Edit Mode viewport. The editor's viewport uses its own transform tools, so a click there selects instead. Play-test the game to try click handlers and see outlines in play.
  • One outline per entity per viewer. A player never sees two at once; plan styles so the most important one is the one you set last, or keep audiences apart.
  • Caps. At most 256 entities per setHighlight call and 256 outlined entities per audience; at most 16 styles per game. Over a cap the call is refused with a diagnostic.
  • 8 clicks per second per player. The host drops the rest.

See also#

Did this page answer your question?