Splintr Plugin Developer Documentation


Version: 1.3
Platform: Splintr Social Network
Audience: Plugin developers and AI assistants generating plugin packages




Overview


Splintr plugins are self-contained browser applications that run inside a sandboxed iframe on a Splintr server. They communicate with the host site through a postMessage JSON API. A plugin can be a game, a tool, a widget — anything that runs in a browser.


Plugins are installed by server administrators, categorized for navigation, and available to all users on that server. They have access to per-user storage, shared storage, and leaderboard functionality through a standardized API, plus federated features: friends' scores and events from other servers, invites to friends on other servers, turn-based matches that span servers, and posts to the player's feed.




Plugin Package Format


A plugin is distributed as a ZIP file with this structure:


my-plugin.zip
├── manifest.json          # Required — plugin metadata and permissions
├── index.html             # Required — entry point loaded in the iframe
├── css/                   # Optional — stylesheets
│   └── game.css
├── js/                    # Optional — scripts
│   └── game.js
├── assets/                # Optional — images, sounds, fonts
│   ├── card-back.png
│   └── click.mp3
└── README.md              # Optional — description for admins

All paths in index.html must be relative — the plugin runs from its own directory on the server. No external CDN resources. No external network requests.




manifest.json


The manifest declares the plugin's identity, requirements, and permissions. The server admin reviews this before installation.


{
  "id": "solitaire",
  "guid": "3f2b8c1e-9a4d-4e6b-8c2f-1a2b3c4d5e6f",
  "name": "Solitaire",
  "version": "1.0.0",
  "description": "Classic Klondike solitaire card game",
  "author": "PluginAuthor",
  "category": "games",
  "genres": ["card"],
  "icon": "assets/icon.png",
  "entry": "index.html",
  "min_players": 1,
  "max_players": 1,
  "permissions": [
    "user_info",
    "data_user",
    "data_shared",
    "leaderboard",
    "events"
  ],
  "data_limits": {
    "user_max_keys": 10,
    "user_max_size_kb": 256,
    "shared_max_keys": 100,
    "shared_max_size_kb": 1024
  },
  "theme_aware": true,
  "mobile_friendly": true,
  "checksums": {
    "index.html": "sha256:a1b2c3d4...",
    "js/game.js": "sha256:e5f6a7b8...",
    "css/game.css": "sha256:c9d0e1f2..."
  }
}

Manifest Fields


FieldRequiredDescription
idYesUnique identifier (lowercase, alphanumeric, hyphens). Must be unique on the server.
guidNo*A UUID you generate once (e.g. 3f2b8c1e-9a4d-4e6b-8c2f-1a2b3c4d5e6f) and never change. It identifies your game across servers. *Required for federated_scores and federated_events.
nameYesDisplay name shown to users.
versionYesSemantic version string (major.minor.patch).
descriptionYesShort description shown in the plugin listing. Also searched by the Games catalog search box.
authorYesAuthor name or handle.
categoryYesTop-level kind of plugin. Use games for games. Admin may override this. See Categories and Genres below.
genresNoArray of genre names from the fixed list in Categories and Genres below (e.g. ["card", "casino"]). Used for filtering in the Games catalog. A plugin can have several. Unknown names are skipped at install.
iconNoRelative path to a square icon image (PNG or SVG, recommended 128x128). Falls back to a default icon.
entryYesRelative path to the HTML entry point.
min_playersNoMinimum players (default: 1). Positive integer.
max_playersNoMaximum players (default: 1). Positive integer, at least min_players. Values > 1 indicate multiplayer support.
permissionsYesArray of permission strings the plugin requires. See Permissions below.
data_limitsNoRequested storage limits. Server enforces its own maximums — these are hints.
theme_awareNoIf true, the plugin accepts theme color variables from the host. Default: false.
mobile_friendlyNoDeclares the plugin works on small screens. Default: false.
checksumsYesSHA-256 hashes of all files. The server verifies these on install and can re-verify at any time. Only files listed here are checked, and an empty object ({}) is accepted. If you change a file, recompute its hash.
federated_channelsNo*Event channels friends on other servers may read, e.g. ["chronicle"]. 1–5 names. *Required for federated_events.
multiplayer_modeNorealtime (default behaviour) or turn_based. Turn-based games can use matches that span servers.
turn_timeout_daysNoTurn-based only: days without a move before the match ends as a forfeit by the player whose turn it is. 1–30, default 7.

Permissions


PermissionDescription
user_infoRead the current user's display name, node address, and whether they are a federated visitor. Never exposes email, password, or private data.
data_userSave and load per-user data (only the current user's own data).
data_sharedRead and write shared plugin data (game world state, multiplayer sessions, etc.). All plugin users can access this.
leaderboardSubmit scores to and read from the plugin's leaderboard.
eventsSubscribe to and publish events on named channels (for multiplayer awareness).
friends_listRead the current user's friends list, here and on other servers (display names and nodes — for multiplayer matching and invites).
matchmakingOpen lobby matchmaking (public games) and sidebar invites (private games with friends, including direct invites to one friend on another server).
feed_postAsk to post to the player's feed (feed.request_post). The player approves each post in a Splintr window (or chooses Always / Never). Home server only — not available to visitors.
federated_scoresRead friends' best scores for this game from their home servers (score.friends). Needs guid and leaderboard.
federated_eventsRead events on your federated_channels from friends' home servers, read-only (events.friends). Needs guid and events.
federated_turnsTurn-based matches (match.*): server-enforced turns and game state, hidden secrets, "your turn" / nudge notices, and players on other servers. Needs "multiplayer_mode": "turn_based", matchmaking and max_players ≥ 2.

Adding a permission in an upgrade disables the plugin until an admin re-enables it (see Updating an Installed Plugin).




The Plugin Bridge API


The plugin communicates with Splintr through window.postMessage(). The host page listens for messages from the plugin iframe and responds.


Sending a Request


// In your plugin's JavaScript:
function splintrRequest(action, data = {}, timeoutMs = 10000) {
  return new Promise((resolve, reject) => {
    const requestId = crypto.randomUUID();
    let timer;
    
    const handler = (event) => {
      // Only accept messages from the parent frame
      if (event.source !== window.parent) return;
      
      // Replies are matched by requestId only. They have no "type" field.
      const response = event.data;
      if (!response || response.requestId !== requestId) return;
      
      clearTimeout(timer);
      window.removeEventListener('message', handler);
      
      if (response.error) {
        // Bridge-level failure (error is a plain string)
        reject(new Error(response.error));
      } else if (response.result && response.result.error) {
        // Server-side failure relayed inside result
        reject(new Error(response.result.error));
      } else {
        resolve(response.result);
      }
    };
    
    window.addEventListener('message', handler);
    
    window.parent.postMessage({
      type: 'splintr_plugin_request',
      requestId: requestId,
      action: action,
      data: data
    }, '*');
    
    // Timeout (10 seconds by default; feed.request_post needs longer, see below)
    timer = setTimeout(() => {
      window.removeEventListener('message', handler);
      reject(new Error('Request timed out'));
    }, timeoutMs);
  });
}

Reply Format and Errors


The host answers every request with a message posted back to the plugin iframe. Replies have no type field. Match them by requestId (and check event.source === window.parent), nothing else.


Successful reply:


{ "requestId": "...", "result": { "...action-specific fields...": "" } }

Failures arrive through one of two channels, so check both:


ChannelShapeTypical causes
Bridge error{ "requestId": "...", "error": "message" }The action isn't allowed by the manifest permissions, the action name is unknown, or the host's call to the server failed (network error, non-JSON response)
Error inside result{ "requestId": "...", "result": { "error": "message" } }Anything the server rejects: missing fields, invalid key, size or key-count limits, data_user / data_shared / leaderboard permission checks, rate limit, plugin disabled, signed-out session

error is always a plain string, never an object. The splintrRequest() function above turns both channels into a rejected promise, so a try/catch (or .catch()) around any Splintr.* call covers every failure. Server errors do not reach plugin code as HTTP status codes. The host page makes the HTTP call and relays the JSON body.


Successful replies are passed through unchanged, so read fields exactly as documented under each action below. For example, data.load returns the stored value in result.value, not in result itself.


Common Mistakes


These have each shipped in real plugins and broken them silently. Check for all of them:


MistakeWhat happensCorrect
Waiting for a reply with type: 'splintr_plugin_response', or reading success / dataEvery request times out; names show as "Guest" and leaderboards never loadMatch on requestId only; read result and error
Reading data.load results directlySaved data never loadsThe stored value is result.value (null if the key doesn't exist)
Reading theme.get colors at the top levelTheme never appliesColors are under result.colors
Showing only display_name on leaderboardsPlayers with the same name are indistinguishable, especially across serversShow node alongside display_name (see score.list)
Calling an action name that isn't listed below (for example leaderboard.submit or feed.post)Rejected by the host as an unknown actionUse the exact action names in the API Actions Reference
Using the default 10-second timeout for feed.request_postThe request "times out" while the player is still reading the post window, and your game says sharing failedAllow up to 5 minutes (300000 ms) for feed.request_post
Waiting for a "bridge ready" message before sending requestsRequests are delayed; no such message existsSend requests as soon as your script runs
Wrapping inputs in a <form> and listening for submitPressing the button or Enter does nothing; no error in your code, only a console warningDon't use <form>. Use type="button" plus a click handler and an Enter-key listener

Convenience Wrapper (Recommended)


Include this at the top of your plugin to simplify API calls. Leave out the parts you have no permission for.


const Splintr = {
  // Get current user info
  async getUserInfo() {
    return splintrRequest('user.info');
  },
  
  // Per-user data storage
  async saveUserData(key, value) {
    return splintrRequest('data.save', { scope: 'user', key, value });
  },
  
  async loadUserData(key) {
    return splintrRequest('data.load', { scope: 'user', key });
  },
  
  async deleteUserData(key) {
    return splintrRequest('data.delete', { scope: 'user', key });
  },
  
  async listUserKeys() {
    return splintrRequest('data.keys', { scope: 'user' });
  },
  
  // Shared data storage
  async saveSharedData(key, value) {
    return splintrRequest('data.save', { scope: 'shared', key, value });
  },
  
  async loadSharedData(key) {
    return splintrRequest('data.load', { scope: 'shared', key });
  },
  
  async deleteSharedData(key) {
    return splintrRequest('data.delete', { scope: 'shared', key });
  },
  
  async listSharedKeys(prefix = '') {
    return splintrRequest('data.keys', { scope: 'shared', prefix });
  },
  
  // Leaderboard
  // `board` is optional: omit it for a single leaderboard, or pass a name
  // (e.g. 'easy', 'hard') to keep separate leaderboards. See score.submit.
  async submitScore(score, meta = {}, board = '') {
    return splintrRequest('score.submit', { score, meta, board });
  },

  async getLeaderboard(limit = 20, offset = 0, board = '') {
    return splintrRequest('score.list', { limit, offset, board });
  },

  async getMyBestScore(board = '') {
    return splintrRequest('score.my_best', { board });
  },

  // Friends' best scores from their home servers (federated_scores)
  async getFriendScores(board = '', limit = 50) {
    return splintrRequest('score.friends', { board, limit });
  },
  
  // Events (multiplayer)
  async subscribe(channel) {
    // Registers interest only. Events use a polling model: your plugin
    // must call pollEvents() on its own timer to receive anything.
    return splintrRequest('events.subscribe', { channel });
  },
  
  async publish(channel, eventData) {
    return splintrRequest('events.publish', { channel, data: eventData });
  },
  
  async pollEvents(channel, since = 0) {
    return splintrRequest('events.poll', { channel, since });
  },

  // Events on a federated channel from friends' home servers (federated_events)
  async pollFriendEvents(channel) {
    return splintrRequest('events.friends', { channel });
  },
  
  // Friends (if permitted)
  async getFriends() {
    return splintrRequest('friends.list');
  },
  
  async getFriendGroups() {
    return splintrRequest('friends.groups');
  },
  
  // Matchmaking (if permitted)
  // Private games — invite friends via sidebar:
  async createGame(visibility = 'friends', options = {}) {
    const data = { visibility };
    if (options.friend_group_id) data.friend_group_id = options.friend_group_id;
    if (options.target_room_code) data.target_room_code = options.target_room_code;
    // Direct invite to one friend, here or on another server
    if (options.target_node) data.target_node = options.target_node;
    return splintrRequest('matchmaking.create_game', data);
  },

  async checkInviteStatus(inviteId) {
    return splintrRequest('matchmaking.check_invite', { invite_id: inviteId });
  },

  // Public games — open lobby for anyone with the plugin open:
  async setAvailable() {
    return splintrRequest('matchmaking.available');
  },
  
  async setUnavailable() {
    return splintrRequest('matchmaking.unavailable');
  },
  
  async listPlayers() {
    return splintrRequest('matchmaking.list');
  },
  
  async sendInvite(roomCode) {
    return splintrRequest('matchmaking.invite', { room_code: roomCode });
  },
  
  async pollInvites() {
    return splintrRequest('matchmaking.poll');
  },
  
  async respondToInvite(inviteId, response) {
    return splintrRequest('matchmaking.respond', { invite_id: inviteId, response: response });
  },

  // Feed (feed_post). Splintr shows the player a window and waits for them,
  // so allow up to 5 minutes. Resolves { posted: true|false }.
  async requestFeedPost(text) {
    return splintrRequest('feed.request_post', { text }, 300000);
  },
  
  // Theme
  async getTheme() {
    return splintrRequest('theme.get');
  }
};

Turn-based match helpers (match.*) are listed under Turn-Based Matches below.


Bridge File


Splintr does not host a bridge script. The <script src="js/splintr-bridge.js"> line in the HTML template refers to a file you put in your ZIP (and list in checksums). Put splintrRequest(), the Splintr wrapper, and the theme listener below in that file, or paste them inline at the top of index.html as the minimal example does.


Theme Injection Listener


If theme_aware is true, the host sends one message to the plugin iframe when it finishes loading:


{ "type": "splintr_theme_inject", "vars": { "--splintr-bg-primary": "#1a1a2e", "--splintr-accent": "#e94560" } }

Nothing is applied for you. The plugin has to listen for this message. Register the listener when your script first runs (before the iframe's load event fires) and apply only --splintr-* properties:


window.addEventListener('message', (event) => {
  if (event.source !== window.parent) return;
  const msg = event.data;
  if (!msg || msg.type !== 'splintr_theme_inject' || !msg.vars) return;
  for (const [name, value] of Object.entries(msg.vars)) {
    if (name.startsWith('--splintr-') && typeof value === 'string') {
      document.documentElement.style.setProperty(name, value);
    }
  }
});

The variables are --splintr- plus the color name with hyphens: bg-primary, bg-secondary, bg-card, text-primary, text-secondary, accent, accent-hover, border, success, error, warning. The values come from the viewing user's active Splintr theme, so they change from user to user. Keep :root fallbacks in your CSS so the plugin still looks right if no message arrives (for example when theme_aware is false).


If your CSS already uses its own variable names, point them at the Splintr ones instead of copying values in JavaScript, for example --accent: var(--splintr-accent, #4da3ff);. Canvas games can't use CSS variables directly; read them with getComputedStyle(document.documentElement) after the inject message arrives.




API Actions Reference


ActionPermissionSection
user.infouser_infouser.info
data.save, data.load, data.delete, data.keysdata_user / data_shared (by scope)data.*
score.submit, score.list, score.my_bestleaderboardscore.*
score.friendsfederated_scoresscore.friends
events.subscribe, events.unsubscribe, events.publish, events.polleventsevents.*
events.friendsfederated_eventsevents.friends
friends.list, friends.groupsfriends_listfriends.list, friends.groups
matchmaking.*matchmakingmatchmaking.*
match.*federated_turnsTurn-Based Matches
feed.request_postfeed_postfeed.request_post
theme.getnonetheme.get

user.info


Returns the current user's public information.


Request: { action: "user.info" }


Response:

{
  "display_name": "Dan",
  "node": "splintr.net/dan",
  "is_federated": false
}

The node field is the player's unique identity across all Splintr servers. Display names are not unique, so keep node if you need to recognize the player later (for example to highlight their own row on a leaderboard).


The is_federated field indicates whether the player is a local user or a visitor from another Splintr server. Game logic should treat both identically. The exceptions are the home-server-only features: feed.request_post, score.friends, events.friends and direct invites by target_node don't work for visitors, so hide those buttons when is_federated is true.


No private information (email, password, settings) is ever exposed.




data.save


Save a JSON value to storage.


Request:

{
  "action": "data.save",
  "data": {
    "scope": "user",
    "key": "character",
    "value": {
      "hp": 45,
      "mp": 12,
      "gold": 230,
      "inventory": ["sword", "shield"],
      "location": "tavern"
    }
  }
}

Scopes:

  • user — Private to the current user. Only they can read/write it. Use for: save games, personal settings, character sheets.
  • shared — Readable and writable by all users of this plugin. Use for: game world state, multiplayer sessions, room contents, shared boards.

Keys: Alphanumeric, hyphens, underscores, colons, and periods. Max 200 characters. Use colon-delimited namespaces for organization: room:tavern, session:abc123, npc:blacksmith.


Values: Any JSON-serializable value except null, which is rejected with "Missing key or value." (use data.delete to clear a key). Server enforces per-plugin size limits.


Response:

{
  "success": true,
  "key": "character",
  "scope": "user",
  "size_bytes": 142,
  "updated_at": "2026-09-27T14:30:00Z"
}



data.load


Load a value from storage.


Request:

{
  "action": "data.load",
  "data": {
    "scope": "user",
    "key": "character"
  }
}

Response:

{
  "key": "character",
  "scope": "user",
  "value": {
    "hp": 45,
    "mp": 12,
    "gold": 230,
    "inventory": ["sword", "shield"],
    "location": "tavern"
  },
  "updated_at": "2026-09-27T14:30:00Z"
}

Returns null for value if the key doesn't exist.




data.delete


Delete a key from storage.


Request:

{
  "action": "data.delete",
  "data": {
    "scope": "user",
    "key": "old-save"
  }
}



data.keys


List keys in a scope, optionally filtered by prefix.


Request:

{
  "action": "data.keys",
  "data": {
    "scope": "shared",
    "prefix": "room:"
  }
}

Response:

{
  "keys": [
    { "key": "room:tavern", "size_bytes": 512, "updated_at": "2026-09-27T14:30:00Z" },
    { "key": "room:forest", "size_bytes": 1024, "updated_at": "2026-09-27T14:25:00Z" }
  ]
}



score.submit


Submit a score to the leaderboard. Each user's best score is retained, per board.


Request:

{
  "action": "score.submit",
  "data": {
    "score": 15200,
    "board": "hard",
    "meta": {
      "level": 5,
      "time_seconds": 342,
      "difficulty": "hard"
    }
  }
}

score must be a number. Higher is better. meta is optional freeform JSON stored alongside the score for display purposes.


board is optional. A plugin can keep several independent leaderboards by naming them, for example one per difficulty level (easy, medium, hard) or one per game mode. Each board ranks separately, and a player has one personal best on each board. Omit board (or pass "") to use the default board; plugins that never pass it have a single leaderboard. Board names are 1-50 characters of lowercase letters, digits, hyphens and underscores; anything else is rejected with result.error. Use the same board name with score.list, score.my_best and score.friends.


Lower-is-better scores (times, move counts): the server always keeps the highest score. Submit an inverted value such as 36000 - seconds, and store the real value in meta (for example meta.time_seconds) so the leaderboard can display it.


Response:

{
  "success": true,
  "board": "hard",
  "is_personal_best": true,
  "rank": 3,
  "previous_best": 12400
}



score.list


Get the leaderboard.


Request:

{
  "action": "score.list",
  "data": {
    "board": "hard",
    "limit": 20,
    "offset": 0
  }
}

board is optional and selects which leaderboard to return (default ""). total_entries, your_rank and your_best are all for that board.


Response:

{
  "board": "hard",
  "entries": [
    {
      "rank": 1,
      "display_name": "PixelWitch",
      "node": "splintr.net/pixelwitch",
      "is_local": true,
      "score": 28400,
      "meta": { "level": 12, "difficulty": "hard" },
      "achieved_at": "2026-09-25T19:00:00Z"
    }
  ],
  "total_entries": 45,
  "your_rank": 3,
  "your_best": 15200
}

Entries carry the display name stored when the score was submitted and the player's node. They do not include user IDs or avatar URLs. score is returned as a number.


Always show node next to display_name. Two players on different servers can share a display name; the node is what tells them apart. To mark the current player's row, compare entry.node with the node from user.info:


function leaderboardName(entry, myNode) {
  const wrap = document.createElement('span');
  const name = document.createElement('span');
  name.textContent = entry.display_name;
  wrap.appendChild(name);
  if (entry.node) {
    const node = document.createElement('small');
    node.textContent = ' ' + entry.node;
    wrap.appendChild(node);
  }
  if (myNode && entry.node === myNode) wrap.classList.add('is-you');
  return wrap;
}

Refresh the leaderboard after score.submit resolves, not at the same time, or the new score may not appear yet.




score.my_best


Get the current user's personal best.


Request: { action: "score.my_best", data: { board: "hard" } } (board optional, default "")


Response:

{
  "score": 15200,
  "meta": { "level": 5, "time_seconds": 342 },
  "achieved_at": "2026-09-27T14:30:00Z",
  "rank": 3
}

Returns null if the user has no scores.




score.friends


Friends' best scores for this game, fetched by the host page from each friend's home server. Requires federated_scores (and a guid). See Friends' Scores Across Servers for the rules.


Request:

{ "action": "score.friends", "data": { "board": "", "limit": 50 } }

board is optional (default ""). limit is 1–100, default 50.


Response:

{
  "entries": [
    {
      "display_name": "Remy",
      "node": "other.example/remy",
      "score": 41,
      "meta": {},
      "achieved_at": "2026-10-20T18:00:00Z",
      "is_you": false,
      "source_server": "other.example"
    }
  ],
  "failed_servers": ["down.example"],
  "fetched_at": 1761350000,
  "retry_after": 60
}

Within 60 seconds of the last fetch you get the previous result back with cached: true and retry_after (seconds), or entries: [] with retry_after if there is no previous result. Visitors get result.error.




events.subscribe


Register interest in a named event channel. The server tracks active subscriptions for zone-based tick processing.


Request:

{
  "action": "events.subscribe",
  "data": {
    "channel": "room:tavern"
  }
}

Response:

{
  "subscribed": true,
  "channel": "room:tavern",
  "active_users": 3
}



events.publish


Publish an event to a channel.


Request:

{
  "action": "events.publish",
  "data": {
    "channel": "room:tavern",
    "data": {
      "type": "player_action",
      "action": "say",
      "message": "Hello everyone!"
    }
  }
}



events.poll


Poll for new events since a given timestamp.


Request:

{
  "action": "events.poll",
  "data": {
    "channel": "room:tavern",
    "since": 1695830400
  }
}

Response:

{
  "events": [
    {
      "id": 12345,
      "channel": "room:tavern",
      "display_name": "PixelWitch",
      "node": "splintr.net/pixelwitch",
      "data": {
        "type": "player_action",
        "action": "enter",
        "message": null
      },
      "timestamp": 1695830450
    }
  ],
  "server_time": 1695830500
}

Use server_time as the since value for the next poll. Each event carries the sender's display_name and node, so a joining player can learn who the host is from the host's first event.




events.unsubscribe


Unsubscribe from a channel (cleanup on leaving a zone, closing the game, etc.).


Request:

{
  "action": "events.unsubscribe",
  "data": {
    "channel": "room:tavern"
  }
}



events.friends


Events on one of your federated_channels, read by the host page from friends' home servers. Requires federated_events (and a guid). The host keeps a cursor per server, so each call returns only events you haven't been given yet. See Friends' Events Across Servers.


Request:

{ "action": "events.friends", "data": { "channel": "chronicle" } }

Response:

{
  "events": [
    {
      "display_name": "Remy",
      "node": "other.example/remy",
      "data": { "type": "level_up", "level": 12 },
      "timestamp": 1761350000,
      "source_server": "other.example"
    }
  ],
  "failed_servers": [],
  "retry_after": 120
}

Events are sorted oldest first. A channel not listed in federated_channels is rejected with result.error.




friends.list


Get the current user's friends: local friends and friends on other Splintr servers. Requires friends_list permission. Federated visitors receive an empty list with a note field, because the friends list is only available on the player's home server.


Request: { action: "friends.list" }


Response:

{
  "friends": [
    { "user_id": 7, "display_name": "PixelWitch", "node": "splintr.net/pixelwitch", "is_remote": false, "avatar_url": "/api/avatar/pixelwitch?size=thumb" },
    { "user_id": null, "display_name": "Remy", "node": "other.example/remy", "is_remote": true }
  ]
}

Use node to identify friends (it's what direct invites take). user_id only exists for local friends. Several friends can share a display name, so show the node when names clash.




friends.groups


The current player's own friend groups (for example "Family" or "Gamer friends"), for sending a friend_group invite. Requires friends_list. Groups with no members are left out. Visitors get an empty list with a note.


Request: { action: "friends.groups" }


Response:

{
  "groups": [
    { "id": 4, "name": "Gamer friends", "member_count": 6 }
  ]
}

Pass id as friend_group_id to matchmaking.create_game:


const { groups } = await Splintr.getFriendGroups();
await Splintr.createGame('friend_group', { friend_group_id: groups[0].id });

Group names are private to the player. Show them in your invite picker, but never save them with data.save in shared scope, publish them as events, or put them in match state.




matchmaking.create_game


Create a game invite that appears in the right sidebar of eligible users. Use this for private games — inviting friends, friend groups, or one specific friend (on this server or another one). The invite shows up outside the plugin iframe, so recipients see it while browsing any page without needing the plugin open. Requires matchmaking permission.


For public games open to anyone, use the open lobby flow (matchmaking.available → matchmaking.list → matchmaking.invite) instead.


Request:

{
  "action": "matchmaking.create_game",
  "data": {
    "visibility": "friends"
  }
}

Visibility options:


ValueWho sees the invite
publicAnyone on this server. Use the open lobby for public games; public invites are never sent to other servers.
friendsThe inviter's friends, on this server and on other servers
friend_groupMembers of one of the inviter's own friend groups (requires friend_group_id), on this server and on other servers
directOne friend, by target_node (from friends.list), on this server or another server. target_room_code (a lobby player's room code) also works for local players.

How invites reach other servers: when you create a friends, friend_group or direct invite, the inviter's server sends it straight to each recipient's home server, addressed to those recipients only. It appears in their sidebar within seconds, without the plugin open. Group names are never sent. When someone accepts, or you create a new invite that replaces it, the copies on other servers are withdrawn.


Who can accept: the server checks again at accept time. A friends invite can only be accepted by someone who is still the inviter's friend, and a friend_group invite only by someone still in that group. public invites can't be accepted from other servers.


Limits: you can only use your own friend groups (otherwise result.error). Each player can send at most 10 friends/friend_group invites and 30 direct invites to other servers per hour, across all games. Past that, createGame returns result.error, so show the message and let the player try later.


Direct invite to one friend, wherever they are:


const friends = (await Splintr.getFriends()).friends;
const pick = friends.find((f) => f.node === chosenNode);
const invite = await Splintr.createGame('direct', { target_node: pick.node });

target_node must be on the inviter's friends list (otherwise result.error: "That person is not on your friends list."), and only works on the inviter's home server.


Response:

{
  "success": true,
  "invite_id": 12,
  "room_code": "HFWXUW2Y",
  "expires_at": "2026-09-28 13:50:00",
  "visibility": "friends"
}

The invite expires after 5 minutes (24 hours for turn-based games). Only one active invite per user per plugin — creating a new one cancels any pending invite.


After creating, poll with matchmaking.check_invite to detect when an opponent accepts. When accepted, start the game using the room_code as the shared session identifier.


Accepting player flow: When a user accepts the invite from the sidebar, the plugin loads with ?room=ROOMCODE in its URL. The plugin should check for this parameter on startup:


const urlParams = new URLSearchParams(window.location.search);
const joinRoom = urlParams.get('room');
if (joinRoom) {
  // This player is joining an existing game
  startGame(joinRoom, 2); // player 2
} else {
  // This player is the host — show the lobby
  showLobby();
}



matchmaking.available


Mark yourself as looking for a public game. Creates an 8-character room code that other players can use to invite you. Only visible to other users who have this plugin open and call matchmaking.list. Requires matchmaking permission.


Request: { action: "matchmaking.available" }


Response:

{
  "success": true,
  "room_code": "XKQF7M2P",
  "status": "available"
}

Your availability auto-expires after 5 minutes of inactivity. Call this again to refresh it, or call matchmaking.unavailable to remove yourself.


matchmaking.unavailable


Remove yourself from the available players list.


Request: { action: "matchmaking.unavailable" }


Response:

{ "success": true, "status": "unavailable" }

matchmaking.list


Get a list of players currently looking for a game on this plugin. Only shows players on the local server who have the plugin page open and have called matchmaking.available. Requires matchmaking permission.


Request: { action: "matchmaking.list" }


Response:

{
  "players": [
    {
      "display_name": "PixelWitch",
      "node": "splintr.net/pixelwitch",
      "is_local": true,
      "room_code": "XKQF7M2P",
      "available_since": "2026-09-27 12:30:00"
    }
  ],
  "server": "splintr.net"
}

matchmaking.invite


Send a game invite to a player by their room code.


Request:

{
  "action": "matchmaking.invite",
  "data": {
    "room_code": "XKQF7M2P"
  }
}

Response:

{
  "success": true,
  "invite_id": 5,
  "room_code": "XKQF7M2P"
}

After sending, poll with matchmaking.check_invite to see if they accepted or declined.


matchmaking.poll


Check for incoming game invites addressed to you.


Request: { action: "matchmaking.poll" }


Response:

{
  "invites": [
    {
      "invite_id": 5,
      "room_code": "XKQF7M2P",
      "from": "PixelWitch",
      "sent_at": "2026-09-27 12:31:00"
    }
  ]
}

Invites expire after 5 minutes if not responded to.


matchmaking.respond


Accept or decline a game invite.


Request:

{
  "action": "matchmaking.respond",
  "data": {
    "invite_id": 5,
    "response": "accepted"
  }
}

Response (accepted):

{
  "success": true,
  "status": "accepted",
  "room_code": "XKQF7M2P",
  "opponent": "PixelWitch",
  "opponent_node": "splintr.net/pixelwitch"
}

When accepted, both players are marked as in_game and removed from the available list. The room_code is the shared identifier both players use to load the game room via shared data.


matchmaking.check_invite


Check the status of an invite you sent (to see if the opponent accepted or declined).


Request:

{
  "action": "matchmaking.check_invite",
  "data": {
    "invite_id": 5
  }
}

Response:

{
  "invite_id": 5,
  "status": "accepted",
  "room_code": "XKQF7M2P",
  "opponent": "PixelWitch",
  "opponent_node": "splintr.net/pixelwitch"
}

Status values: pending, accepted, declined, expired.




feed.request_post


Ask to post a short message to the player's feed. Requires feed_post. See Posting to the Feed for the rules.


Request:

{ "action": "feed.request_post", "data": { "text": "I scored 12,340 in Breakout! #breakout" } }

Response: { "posted": true } or { "posted": false }


The host shows the player a window and waits for their answer, which can take minutes. Give this request a long timeout (300000 ms), or your bridge reports a timeout while the player is still deciding. The plugin can't call the server's own feed actions (feed.status, feed.post, feed.set_grant); only the host page uses those, after the player chooses.




theme.get


Get the viewing user's active theme colors so the plugin can match the site's look. The host page answers this from the theme it has loaded, so the values match what the user sees. If theme_aware is true, the host also pushes the same colors to the plugin on load as CSS variables; see Theme Injection Listener above. Most plugins only need the inject listener.


The colors are inside colors, not at the top level of result. The values below are examples only.


Request: { action: "theme.get" }


Response:

{
  "colors": {
    "bg_primary": "#1a1a2e",
    "bg_secondary": "#16213e",
    "bg_card": "#1e2a45",
    "text_primary": "#e0e0e0",
    "text_secondary": "#a0a0a0",
    "accent": "#e94560",
    "accent_hover": "#ff6b81",
    "border": "#2a2a4a",
    "success": "#4caf50",
    "error": "#f44336",
    "warning": "#ff9800"
  }
}



Tick-Based Game Loop (For Complex Games)


For games that need a world simulation (MUDs, strategy games, persistent worlds), use a player-driven tick model. There is no server-side cron job and the server runs no game logic: it only stores events and shared data. The game world advances only when players' browsers advance it.


How It Works


  1. A player's browser polls at regular intervals (e.g., every 3-6 seconds) via events.poll.
  2. Your plugin checks the zone's shared state (for example a last_tick timestamp stored with data.save in shared scope) to see whether a tick is due.
  3. If one is due, that browser runs your tick logic, saves the new zone state, and publishes the results as events.
  4. Other players receive the results on their next events.poll.

Because any player's browser may run the tick, design ticks so that running one twice is harmless (for example, compare last_tick again just before saving).


Implementing Ticks in Your Plugin


// Game constants
const TICK_INTERVAL_MS = 6000; // 6-second ticks, like EverQuest

class GameLoop {
  constructor() {
    this.lastServerTime = 0;
    this.running = false;
    this.currentZone = null;
  }
  
  async start(zone) {
    this.currentZone = zone;
    this.running = true;
    
    // Subscribe to the zone's event channel
    await Splintr.subscribe(`zone:${zone}`);
    
    // Start the poll loop
    this.poll();
  }
  
  async poll() {
    if (!this.running) return;
    
    try {
      // Poll for events — this also triggers the server-side tick if due
      const result = await Splintr.pollEvents(
        `zone:${this.currentZone}`,
        this.lastServerTime
      );
      
      // Process any events that occurred
      for (const event of result.events) {
        this.handleEvent(event);
      }
      
      this.lastServerTime = result.server_time;
    } catch (err) {
      console.error('Poll error:', err);
    }
    
    // Schedule next poll
    setTimeout(() => this.poll(), TICK_INTERVAL_MS);
  }
  
  handleEvent(event) {
    switch (event.data.type) {
      case 'tick':
        this.processTick(event.data);
        break;
      case 'player_enter':
        this.showMessage(`${event.display_name} enters the area.`);
        break;
      case 'player_action':
        this.handlePlayerAction(event);
        break;
      case 'combat':
        this.handleCombat(event.data);
        break;
      // ... etc
    }
  }
  
  async stop() {
    this.running = false;
    if (this.currentZone) {
      await Splintr.publish(`zone:${this.currentZone}`, {
        type: 'player_leave'
      });
      await splintrRequest('events.unsubscribe', {
        channel: `zone:${this.currentZone}`
      });
    }
  }
}

Zone Activity and Despawning


The plugin should handle staleness itself. Store a last_tick timestamp in the zone's shared data and compare it with server_time from events.poll:


  • Active zone: At least one player is polling. Their browser runs ticks as they come due.
  • Idle zone: Nobody is polling. No ticks run and no resources are used.
  • Stale zone: last_tick is older than a threshold you choose (e.g., 5 minutes). The first player to return should reset the zone (despawn temporary entities, reset combat, restore defaults) before resuming ticks.

The server does not send zone_reset or any other tick events on its own. Events are stored for one hour, and each channel keeps at most the 500 most recent.




Plugin HTML Template


A minimal starting point for index.html:


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style id="splintr-scrollbars">::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--splintr-bg-secondary,#16213e)}::-webkit-scrollbar-thumb{background:var(--splintr-accent,#e94560);border-radius:6px}::-webkit-scrollbar-thumb:hover{background:var(--splintr-accent-hover,#ff6b81)}::-webkit-scrollbar-corner{background:var(--splintr-bg-secondary,#16213e)}*{scrollbar-width:thin;scrollbar-color:var(--splintr-accent,#e94560) var(--splintr-bg-secondary,#16213e)}</style>
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Plugin</title>
  <link rel="stylesheet" href="css/game.css">
</head>
<body>
  <div id="game-container">
    <!-- Your game UI here -->
  </div>
  
  <script src="js/splintr-bridge.js"></script>
  <script src="js/game.js"></script>
</body>
</html>

CSS Considerations


  • The plugin runs in an iframe. It has its own document, its own styles, its own DOM.
  • There are no Splintr CSS files loaded — you style everything from scratch.
  • If theme_aware is true, the host sends the theme colors in a splintr_theme_inject message when the plugin loads. Your plugin must listen for it and set them on :root (see Theme Injection Listener). Once applied, you can use them: color: var(--splintr-text-primary, #333);
  • Design for the iframe's available space. On desktop this is typically ~600-900px wide. On mobile it fills the viewport. Use responsive design.
  • The iframe fills everything below a slim host bar (a "← Games" link and a pop-out button — no title). Draw your game's own title; the host doesn't repeat it. The host page itself never scrolls, so if your content is taller than the frame, your document scrolls.
  • Host scrollbar styles can't reach into the sandbox. Keep the <style id="splintr-scrollbars"> block from the template above so your scrollbars match the user's theme. It sits before your own CSS, so any scrollbar styling you define yourself wins. (tools/add_plugin_scrollbars.py adds it to existing plugins.)

Theme-Aware CSS Example


:root {
  /* Fallbacks for when theme vars aren't injected */
  --splintr-bg-primary: #1a1a2e;
  --splintr-text-primary: #e0e0e0;
  --splintr-accent: #e94560;
}

body {
  background: var(--splintr-bg-primary);
  color: var(--splintr-text-primary);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  margin: 0;
  padding: 16px;
}

button {
  background: var(--splintr-accent);
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}



Security Rules for Plugins


These are enforced by the iframe sandbox and server. Violating them causes silent failures, not errors.


  1. No external network requests. The iframe's CSP blocks all outbound connections. All data goes through the postMessage bridge. (Federated features work because the host page, not your plugin, talks to other servers.)
  2. No access to the parent page. The iframe sandbox prevents DOM access, cookie access, and navigation of the parent frame.
  3. No eval() or new Function(). The CSP blocks dynamic code execution. Games do not need these — use regular functions, event handlers, and state objects.
  4. No localStorage/sessionStorage. Use the Splintr data API instead — it persists across sessions and devices.
  5. No popups or new windows from plugin code. window.open() is blocked by the iframe sandbox. However, the host page provides a pop-out button (⧉) that opens the plugin in its own browser window outside the app shell. The plugin's session and bridge API work normally in the pop-out window. Plugins don't need to do anything special to support this.
  6. GET and POST only. The Splintr plugin API accepts only GET and POST HTTP methods. Do not use PUT, DELETE, PATCH, or any other HTTP verbs — they will be rejected. All destructive operations use POST with an action field.
  7. Use DOM methods, not innerHTML. Build UI elements with document.createElement(), appendChild(), and textContent instead of setting innerHTML with markup strings. This avoids injection risks and prevents false positives from security software. The exception is static markup in your initial HTML file — dynamically generated content at runtime should use DOM methods.
  8. Key names are validated. Only alphanumeric characters, hyphens, underscores, colons, and periods. Max 200 characters.
  9. Data values are size-limited. The server enforces per-plugin limits. Default: 256KB per user key, 1MB per shared key, configurable by admin.
  10. Rate limiting applies. All requests from a plugin share one budget per minute: 30 for most actions, 60 when the request is an event poll/publish or a matchmaking call. See Rate Limits below.
  11. No <form> submission. The iframe sandbox doesn't include allow-forms, so submitting a <form> is silently blocked and its submit handler never runs. Inputs work normally: use a type="button" button with a click handler (plus an Enter-key listener on the input) and read the values in JavaScript, then save them with data.save.
  12. Treat data from other servers as untrusted. Entries from score.friends, events from events.friends, and anything in match state written by a player on another server can contain anything. Check types and ranges before using them, and render text with textContent.

Rate Limits


API calls from plugins are rate-limited per plugin per session, using a single counter over a 60-second window. Every action counts toward it, including user.info, theme.get, score.*, and data.*:


  • Most actions (user.info, data.*, score.*, theme.get, events.subscribe, events.unsubscribe, friends.list): rejected once the window's count passes 30
  • events.poll, events.publish, and matchmaking.*: rejected once the window's count passes 60

Because the counter is shared, polling uses up the same budget as everything else. A plugin that polls every 3 seconds (20 requests/minute) leaves only about 10 requests/minute for saves, score submits, and other actions.


score.friends and events.friends have their own refresh windows on top of this (60 and 120 seconds); calls inside the window return cached or empty results with retry_after. Feed posts are capped at 10 per player per hour per game.


Sidebar invites are capped per player across all games: 10 friends/friend_group invites and 30 direct invites to other servers per hour.


When the limit is exceeded the server returns {"error": "Rate limit exceeded. Try again shortly."} (HTTP 429), which your plugin receives as result.error and your bridge rejects. Design your polling intervals accordingly. 3-second intervals for event polling is a safe default for turn-based games.




Categories and Genres


Splintr organizes plugins on two levels:


  • category is the top-level kind of plugin. It decides where the plugin appears in the site's navigation and is part of its URL: /plugins/{category}/{plugin-id}. Use games for all games. The admin may override it at install time.
  • genres are finer tags within the Games catalog, used for filtering. A game can have several (Blackjack is both card and casino).

Categories


CategoryDescriptionURL pattern
gamesGames of all types. Listed under Games in the left navigation./plugins/games/solitaire

Keep category stable once a plugin is published. It appears in game URLs and in federated game invites, so changing it changes where existing links point.


Genres


Genres come from a fixed list so the catalog's filter chips stay clean across plugins from different authors. Pick every genre that genuinely fits.


GenreUse forExamples
cardGames played with a deck of cardsSolitaire, Blackjack, Poker
casinoGambling-style gamesBlackjack, Poker, slots
arcadeReal-time action and reflex gamesBreakout, Space Invaders, Coin Pusher
puzzleLogic and problem-solvingMinesweeper, Sudoku
wordSpelling and vocabularyHangman, word search
boardBoard gamesConnect Four, chess, checkers
strategyPlanning-heavy gamesReversi, tower defense
diceDice gamesYahtzee-style, backgammon
triviaQuiz and knowledge gamesTrivia rounds
partyCasual games for groupsDrawing, guessing

Genre names must be lowercase and exactly as listed. Unknown genre names do not block installation: they're skipped and the admin is told which ones were ignored. The admin can also change a plugin's genres at any time from the plugin management page.


How players find games


The Games page has a search box (matches the plugin's name and description), a Players filter (Any, 1, 2, 3+) and a Genre filter. The Players filter uses min_players and max_players: a game appears under "2 players" if its range includes 2. Set both accurately. A single-player game that also supports a two-player mode should declare "min_players": 1, "max_players": 2.


Only genres that at least one enabled game uses are shown as filter chips, so a small server isn't cluttered with empty filters.




Multiplayer Patterns


Splintr provides two matchmaking flows for different use cases:


  • Open Lobby — for public games. Players who have the plugin open can browse who else is looking for a game and challenge them. Both players must be on the plugin page.
  • Sidebar Invites — for private games with friends. The invite appears in the recipient's right sidebar, visible from any page. The recipient doesn't need the plugin open.

A multiplayer plugin can support one or both flows.


Open Lobby (Public Games)


Players on the plugin page mark themselves as available. Others browsing the lobby can see them and send a direct invite.


// Player clicks "Find Game" — mark as available
const availability = await Splintr.setAvailable();
const myRoomCode = availability.room_code; // e.g. "XKQF7M2P"

// List other available players on this server
const result = await Splintr.listPlayers();
// result.players = [{ display_name, room_code, is_local, available_since }, ...]

// Send an invite to a specific player
const invite = await Splintr.sendInvite(targetRoomCode);

// Poll to see if they accepted
const poll = setInterval(async () => {
  const status = await Splintr.checkInviteStatus(invite.invite_id);
  if (status.status === 'accepted') {
    clearInterval(poll);
    startGame(status.room_code, status.opponent);
  } else if (status.status === 'declined' || status.status === 'expired') {
    clearInterval(poll);
    showMessage('Invite was ' + status.status);
  }
}, 3000);

// Meanwhile, poll for incoming invites from others
const invites = await Splintr.pollInvites();
// invites.invites = [{ invite_id, room_code, from, sent_at }, ...]

// Accept an incoming invite
const accepted = await Splintr.respondToInvite(inviteId, 'accepted');
startGame(accepted.room_code, accepted.opponent);

The lobby is local-only — it shows players on the same server.


Sidebar Invites (Private Games)


The host creates an invite with a visibility level. It appears in the right sidebar of eligible users — friends, friend groups, or one specific friend — visible from any page without needing the plugin open.


Host side:


// Player clicks "Start Game" — invite friends
const result = await Splintr.createGame('friends');
// or one friend, here or on another server:
// const result = await Splintr.createGame('direct', { target_node: friend.node });
// result = { invite_id, room_code, expires_at, visibility }

// Poll until someone accepts
const poll = setInterval(async () => {
  const status = await Splintr.checkInviteStatus(result.invite_id);
  if (status.status === 'accepted') {
    clearInterval(poll);
    startGame(result.room_code, 1, status.opponent); // I'm player 1
  } else if (status.status === 'expired') {
    clearInterval(poll);
    showLobby(); // Invite expired, let them try again
  }
}, 3000);

A friend picker for direct invites: load friends.list once, offer the friends in an <input> with a <datalist> (long lists don't fit a <select>), label duplicates with their node, and send the chosen friend's node. Hide the picker for visitors (is_federated), since direct invites only work on the home server.


Joining side:


When a user accepts the invite from the sidebar, the plugin loads with ?room=ROOMCODE in its URL. Check for this on startup:


const urlParams = new URLSearchParams(window.location.search);
const joinRoom = urlParams.get('room');

if (joinRoom) {
  // I'm the joining player (player 2)
  // Publish a join event so the host knows I'm here
  await Splintr.publish('game_' + joinRoom, {
    type: 'player_joined',
    player: 2,
    name: myDisplayName
  });
  startGame(joinRoom, 2);
} else {
  // I'm the host — show the lobby
  showLobby();
}

Sidebar invites reach friends on other servers directly: the invite is delivered to their home server and shows in their sidebar there. Realtime games are played on the inviter's server, so the friend joins as a visitor. For games that last days, use turn-based matches.


Turn-Based Short Games (Card Games, Board Games)


Use shared data to store the game session state:


// Create a game session
await Splintr.saveSharedData('session:abc123', {
  players: [hostNode, guestNode],   // node from user.info
  current_turn: hostNode,
  board: initialBoardState,
  started_at: Date.now()
});

// Players poll for updates
const session = await Splintr.loadSharedData('session:abc123');
if (session.value.current_turn === myNode) {
  // It's my turn — enable controls
}

Turn-Based Matches


Long games like word games or drawing games last days, and players come and go. Matches handle the bookkeeping. They need federated_turns, matchmaking, "multiplayer_mode": "turn_based" and max_players ≥ 2.


Match Helpers


Add these to your Splintr wrapper (Sketch & Guess keeps them in js/match-store.js):


const opt = (v) => (v === undefined ? null : v);
Object.assign(Splintr, {
  startMatch:     (roomCode, firstSeat) => splintrRequest('match.start', { room_code: roomCode, first_seat: opt(firstSeat) }),
  getMatch:       (code) => splintrRequest('match.get', { match_code: code }),
  listMatches:    () => splintrRequest('match.list'),
  saveMatchState: (code, state, secret) => splintrRequest('match.save_state', { match_code: code, state: opt(state), secret: opt(secret) }),
  endTurn:        (code, state, nextSeat, secret) => splintrRequest('match.end_turn', { match_code: code, state: opt(state), next_seat: opt(nextSeat), secret: opt(secret) }),
  nudge:          (code) => splintrRequest('match.nudge', { match_code: code }),
  quitMatch:      (code) => splintrRequest('match.quit', { match_code: code }),
  finishMatch:    (code, winnerSeat, state) => splintrRequest('match.finish', { match_code: code, winner_seat: opt(winnerSeat), state: opt(state) }),
  guessSecret:    (code, guess) => splintrRequest('match.guess', { match_code: code, guess }),
  revealSecret:   (code) => splintrRequest('match.reveal', { match_code: code }),
  rematchMatch:   (code, state) => splintrRequest('match.rematch', { match_code: code, state: opt(state) })
});

ActionDataWhat it does
match.startroom_code, first_seat ('acceptor' or null)Creates the match from an accepted invite. Returns pending: true until both players are in. With 'acceptor', the player who accepted moves first.
match.getmatch_codeThe match view: match_code, status, current_seat, your_seat, your_turn, turn_number, players: [{seat, display_name, node}], state, winner_seat, ended_reason.
match.list—Your matches for this game, each with match_code, turn_number, last_move_at, status. Cheap to poll; compare turn_number + last_move_at to see whether a match changed before calling match.get.
match.save_statematch_code, state, secretSaves state without passing the turn. Only on your turn. Counts as activity.
match.end_turnmatch_code, state, next_seat, secretSaves state, passes the turn (to next_seat, or the other player) and sends them "Your turn".
match.nudgematch_codeReminds the player whose turn it is. Once every 4 hours.
match.quitmatch_codeLeave the match; it ends as a forfeit.
match.finishmatch_code, winner_seat (null = draw), stateEnds the match on your final move.
match.guessmatch_code, guessOn your turn, tests a guess against the secret the other seat set. Returns { correct, tries }, plus answer when correct. Case and spaces are ignored. Guesses per secret are capped.
match.revealmatch_codeGive up: on your turn, returns { answer, tries } for the other seat's secret.
match.rematchmatch_code, stateAfter a match ends, starts a new one with the same players; you move first. Returns the new match view. Calling it again (from either player) returns the same rematch.

Flow


const params = new URLSearchParams(location.search);
let code = params.get('match') || params.get('room');   // from "Your Games" or an invite

// After an invite is accepted (both players call this; the inviter waits for the other player)
let m = await Splintr.startMatch(code);
if (m.pending) { showWaiting(); /* poll startMatch again in a few seconds */ }

// Opening an existing match
m = await Splintr.getMatch(code);

if (m.your_turn) {
  // ... player makes a move ...
  await Splintr.endTurn(code, newState);      // saves state, passes the turn, notifies the other player
}

await Splintr.nudge(code);                    // remind them (once every 4 hours)
await Splintr.finishMatch(code, winnerSeat, finalState);  // on the final move; null winner = draw
await Splintr.quitMatch(code);

  • State: the server keeps one JSON state per match (max 256KB). Only the player whose turn it is can write it. Every player in the match can read it, and nobody else can.
  • Secrets: pass secret (a string) with match.save_state or match.end_turn to store something the other player must not see, such as the word being drawn. Only the seat that set it can read it back. The other player can only test it with match.guess or give up with match.reveal. '' clears it. Setting a new secret resets the guess count.
  • Where play happens: the match lives on the inviter's server. Players from other servers open it from Your Games in their sidebar and play there as visitors. You don't need to do anything special for that.
  • Notices: the other player gets "Your turn", "nudged you", "left the game" or "Game over" in their sidebar, even if they're on another server and don't have your game open.
  • Quiet while you're watching: a player who has the match open (polling match.get or match.list) doesn't get a "Your turn" sidebar notice for it; the game is already showing them. Clicking a notice opens the match and clears it.
  • Invites: turn-based invites last 24 hours instead of 5 minutes.
  • Hidden information: don't keep anything the opponent shouldn't see in the state. Everything in it is readable by every player in the match. Use a secret instead.
  • Polling: poll match.list (every 8 seconds is plenty, 30 when the tab is hidden) and only call match.get when a match changed, especially if state is large.
  • Two players per match for now.

Communicating Moves (Turn-Based Games)


Use the events system with the room code as the channel name. This works the same regardless of whether the game started from the open lobby or a sidebar invite.


// Publish a move
await Splintr.publish('game_' + roomCode, {
  type: 'move',
  player: myPlayerNumber,
  col: 3,
  row: 2
});

// Poll for opponent's moves (every 3 seconds)
let lastTimestamp = 0;
const movePoll = setInterval(async () => {
  const result = await Splintr.pollEvents('game_' + roomCode, lastTimestamp);
  if (result.events) {
    result.events.forEach(evt => {
      lastTimestamp = Math.max(lastTimestamp, evt.timestamp || 0);
      if (evt.data.type === 'move' && evt.data.player !== myPlayerNumber) {
        applyOpponentMove(evt.data);
      }
    });
  }
}, 3000);

Important: The since parameter in pollEvents is a Unix timestamp, not an event ID. Use evt.timestamp from the response, not evt.id.


Real-Time Awareness (MUD, Chat, Presence)


Use the events system for player awareness:


// Player enters a room
await Splintr.publish('zone:tavern', {
  type: 'player_enter',
  display_name: 'Dan'
});

// Poll for other players' actions
const events = await Splintr.pollEvents('zone:tavern', lastTimestamp);



Persistent Game State (MUD Example)


For complex games with character progression, inventory, stats, and world state:


Character Data (User Scope)


// Save character
await Splintr.saveUserData('character', {
  name: 'Aragorn',
  class: 'warrior',
  level: 5,
  hp: 45, hp_max: 60,
  mp: 12, mp_max: 20,
  stats: { str: 18, dex: 14, con: 16, int: 10, wis: 12, cha: 8 },
  gold: 230,
  xp: 4500, xp_next: 6000,
  inventory: [
    { id: 'iron-sword', name: 'Iron Sword', type: 'weapon', damage: '2d6' },
    { id: 'leather-armor', name: 'Leather Armor', type: 'armor', defense: 3 }
  ],
  equipment: {
    weapon: 'iron-sword',
    armor: 'leather-armor',
    shield: null
  },
  location: 'zone:tavern',
  quests: [
    { id: 'rat-cellar', status: 'active', progress: 3, target: 5 }
  ]
});

World State (Shared Scope)


// Room/zone state
await Splintr.saveSharedData('zone:tavern', {
  name: 'The Rusty Flagon',
  description: 'A dimly lit tavern smelling of ale and wood smoke.',
  exits: { north: 'zone:market', east: 'zone:alley', down: 'zone:cellar' },
  npcs: [
    { id: 'bartender', name: 'Grog', dialogue_state: 'idle' }
  ],
  items: [],
  last_tick: 1695830400,
  active_players: ['dan', 'pixelwitch']
});



Posting to the Feed


With the feed_post permission a game can ask to post a short message to the player's feed, using feed.request_post:


// In the wrapper: splintrRequest('feed.request_post', { text }, 300000)
const result = await Splintr.requestFeedPost('Just cleared level 12 of Pachinko with 48,200 points! #pachinko');
if (result.posted) {
  showToast('Shared!');
}

  • Splintr, not your game, shows the player a window with the text and a privacy choice: Post once, Always post from this game, Don't post, Never for this game or Never for any game. Closing the window counts as don't post.
  • The player may take a while to answer. Use a 5-minute timeout (300000 ms) for this request, and show something like "Waiting for you…" on the button meanwhile.
  • Your game only learns posted: true or false. It is never told why a post didn't happen.
  • Plain text up to 1,000 characters. Markdown and #hashtags work as in any post. Images aren't supported yet.
  • Only one window at a time. A request sent while one is open returns posted: false.
  • Visitors from other servers can't post, and neither can players acting as a group or business account. Hide the share button when user.info says is_federated.
  • Posts show a "via Your Game" badge, and players can hide game posts from their feed.
  • Ask at natural moments (a new high score, a finished game, a milestone), not on a timer, and let the player press a button rather than opening the window unprompted. A game is capped at 10 posts per player per hour.

Examples: Breakout shows Share score in its Game Over box after a new personal best. Alchemy Lab shows Share to your feed when its notebook reaches 10, 25, 50, 75 … elements and in its "notebook full" celebration, and remembers the last milestone offered in its save so reloading doesn't offer it again.




Friends' Scores Across Servers


Players on other Splintr servers keep their scores on their own server. With federated_scores (and a guid) your game can show them, using score.friends:


const local = await Splintr.getLeaderboard(20, 0, '');   // this server
const friends = await Splintr.getFriendScores('', 50);   // friends' servers
// friends.entries: [{ display_name, node, score, meta, achieved_at, is_you, source_server }, ...]

  • Scores are only shared between copies of a game with the same guid and the same major version. 1.0.0 and 1.4.2 share scores, 2.0.0 doesn't. Bump the major version when you change how scoring works.
  • Each player appears once, with their best score. is_you marks the current player's own scores from playing on other servers as a visitor. If they've played on several, you get the highest.
  • Your local leaderboard isn't changed. Showing two lists (Local / Friends) or merging them into one is up to you.
  • Results aren't stored. Calling again within 60 seconds returns the last result with cached: true and retry_after (seconds), so you can disable a Refresh button until then.
  • meta is the same object the player's server stored with the score (for example a hero card). Treat it as untrusted: validate it before drawing it, and use textContent.
  • Servers that couldn't be reached are listed in failed_servers.
  • Players choose who on other servers can see their game data (Friends, Friends of friends or Nobody) in Settings → Games.
  • Visitors can't use this. It only works on the player's home server.

Merging into one leaderboard


The same player can show up in both lists (a local player who has also played on a friend's server, or your own is_you scores). Merge by node, keep the highest score, then rank:


function mergeBoards(local, friends) {
  const best = new Map();
  [...(local.entries || []), ...(friends.entries || [])].forEach((e) => {
    const score = Number(e.score);
    if (!Number.isFinite(score) || typeof e.node !== 'string' || !e.node) return;
    const key = e.node.toLowerCase();
    const had = best.get(key);
    if (!had || score > had.score) best.set(key, { display_name: String(e.display_name || 'Player'), node: e.node, score });
  });
  const rows = [...best.values()].sort((a, b) => b.score - a.score);
  rows.forEach((r, i) => { r.rank = i && r.score === rows[i - 1].score ? rows[i - 1].rank : i + 1; });
  return rows;
}

Fetch both with Promise.allSettled so the local board still shows when friends' servers are unavailable, and work out the player's own rank from the merged list rather than your_rank. Alchemy Lab's Ledger does this.


This server / Friends tabs


Most bundled games show two tabs instead, using the shared js/leaderboard.js (FriendsBoard):


  • This server — score.list as stored here, with the note "Your best: … · #2 of 14 on this server".
  • Friends — only the player and their friends, local or remote: friends' rows from this server's board (top 100, matched against friends.list by node), plus score.friends, plus the player's own score (their local best, and is_you entries from other servers). One row per node, best score. Notes: "You're #2 of 6 among you and your friends.", "None of your friends have a score yet.", "Play a game to join your friends here.", "No scores from you or your friends yet.", with "1 server didn't answer" added when failed_servers isn't empty.

const lb = new FriendsBoard({
  mount: document.getElementById('lb'),
  board: 'hard',                                  // setBoard() to switch
  format: (score, meta) => meta.time_seconds + 's',
  empty: 'No scores yet.',
  me: () => ({ name, node, visitor })            // from user.info
});
lb.refresh();                                     // after score.submit resolves

It needs leaderboard, friends_list and federated_scores (and a guid). The friends list is read once per session.




Friends' Events Across Servers


For activity feeds like a chronicle or "what your friends are up to", list the channel in federated_channels and add federated_events:


"permissions": ["events", "federated_events"],
"guid": "3f2b8c1e-9a4d-4e6b-8c2f-1a2b3c4d5e6f",
"federated_channels": ["chronicle"]

// Local events, as before
await Splintr.subscribe('chronicle');
const local = await Splintr.pollEvents('chronicle', since);

// Friends on other servers (events.friends): returns only events you haven't been given yet
const remote = await Splintr.pollFriendEvents('chronicle');
remote.events.forEach(ev => addToChronicle(ev.display_name, ev.data, ev.source_server));

  • Read-only: players can't publish to another server's channel.
  • Only channels in federated_channels are readable from outside. Keep multiplayer room channels out of the list.
  • Calls are limited to one every 2 minutes per channel. Calls in between return events: [] with retry_after, so polling more often is harmless but pointless.
  • Events expire after an hour on every server, so the first call returns at most the last hour.
  • data comes from another server. Validate it and render it with textContent.
  • The same Game Data setting (Friends / Friends of friends / Nobody) as for scores decides whose events you see.
  • Visitors can't use this. It only works on the player's home server.



Checklist: Before Submitting a Plugin


  • [ ] All file paths in index.html are relative
  • [ ] No external URLs (CDNs, APIs, remote images)
  • [ ] No eval(), new Function(), or dynamic code generation
  • [ ] manifest.json has all required fields
  • [ ] Games use "category": "games" and list their genres from the fixed genre list
  • [ ] min_players and max_players reflect every mode the game supports
  • [ ] checksums in manifest match actual file hashes (SHA-256)
  • [ ] Plugin works at various iframe widths (320px to 900px+)
  • [ ] Plugin handles API errors gracefully (timeouts, missing data, and error fields, including ones inside result)
  • [ ] Bridge replies are matched on requestId and read from result / error (no type, success or data fields)
  • [ ] If theme_aware is true, the plugin listens for splintr_theme_inject and its colors come from --splintr-* variables
  • [ ] Leaderboards show each player's node next to their display_name
  • [ ] Federated features have a guid, and data from other servers is validated and rendered with textContent
  • [ ] Home-server-only features (feed posts, friends' scores and events, direct invites) are hidden for visitors
  • [ ] feed.request_post uses a long timeout and is triggered by the player, at a natural moment
  • [ ] Plugin cleans up on unload (unsubscribe from events)
  • [ ] No sensitive data stored in shared scope (user passwords, tokens, etc.)
  • [ ] Friend group names (from friends.groups) are only shown to the player, never stored or published
  • [ ] README.md explains what the plugin does and any admin configuration needed
  • [ ] No <form> elements; buttons are type="button" with click handlers
  • [ ] The game draws its own title, and index.html includes the splintr-scrollbars style block
  • [ ] Saved data carries a version number, and loading handles saves written by older versions

Updating an Installed Plugin


Plugins upgrade in place. Bump version in manifest.json (and recompute the checksum of every file you changed — tools/package_plugins.py --bump does both), then the admin either uploads the new ZIP on the Installed tab or clicks Upgrade on the plugin's card in the directory.


  • Saved data is kept. User data, shared data, scores and leaderboards, and events all stay attached to the plugin. Only uninstalling deletes them.
  • Versions only move forward. Uploading the same version or an older one is refused.
  • New permissions disable the plugin. If the new manifest asks for a permission the installed version didn't have, the upgrade completes but the plugin is disabled until an admin reviews the permissions and re-enables it. Plan permission changes as deliberate releases (adding feed_post or federated_scores is one).
  • Kept from the installed copy: the category (which may be an admin override) and the enabled state. Everything else — name, description, entry file, player counts, genres, permissions — comes from the new manifest.
  • Files are replaced as a set. Files you dropped from the new version are removed. The host revalidates plugin files on every load, so players get the new version on their next page load.
  • Adding a guid later is fine, but never change it once published: it's how other servers recognize the game's scores and events.

Your saved data outlives your code, so version it. A save from v1.0.0 will be loaded by v1.1.0. Store a schema number with anything you save and migrate old shapes when you load:


const SAVE_VERSION = 2;

async function loadSave() {
  const res = await Splintr.loadUserData('save');
  let save = res.value;
  if (!save) return newSave();
  if (!save.v) save = { v: 1, chips: save.chips ?? 1000 };          // v1.0.0 saves had no version
  if (save.v === 1) save = { v: 2, chips: save.chips, stats: { wins: 0, losses: 0 } };
  return save;
}

Never remove or rename a leaderboard board you've already published unless you mean to orphan those scores.




Example: Minimal Working Plugin


Here's the smallest possible working plugin — a click counter with a leaderboard:


manifest.json:

{
  "id": "click-counter",
  "name": "Click Counter",
  "version": "1.0.0",
  "description": "Click as fast as you can in 10 seconds!",
  "author": "SplintrDemo",
  "category": "games",
  "genres": ["arcade"],
  "entry": "index.html",
  "min_players": 1,
  "max_players": 1,
  "permissions": ["user_info", "data_user", "leaderboard"],
  "theme_aware": true,
  "mobile_friendly": true,
  "checksums": {}
}

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Click Counter</title>
  <style>
    :root {
      --splintr-bg-primary: #1a1a2e;
      --splintr-bg-card: #1e2a45;
      --splintr-text-primary: #e0e0e0;
      --splintr-accent: #e94560;
    }
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      background: var(--splintr-bg-primary);
      color: var(--splintr-text-primary);
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      display: flex; flex-direction: column; align-items: center;
      justify-content: center; min-height: 100vh; padding: 20px;
    }
    h1 { margin-bottom: 20px; }
    #click-btn {
      background: var(--splintr-accent); color: white; border: none;
      padding: 30px 60px; font-size: 24px; border-radius: 12px;
      cursor: pointer; user-select: none; transition: transform 0.1s;
    }
    #click-btn:active { transform: scale(0.95); }
    #click-btn:disabled { opacity: 0.5; cursor: not-allowed; }
    #score { font-size: 48px; margin: 20px 0; }
    #timer { font-size: 24px; margin-bottom: 20px; }
    #leaderboard {
      background: var(--splintr-bg-card); border-radius: 8px;
      padding: 16px; margin-top: 20px; width: 100%; max-width: 400px;
    }
    .lb-entry {
      display: flex; justify-content: space-between;
      padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);
    }
  </style>
</head>
<body>
  <h1>Click Counter</h1>
  <div id="timer">10.0s</div>
  <div id="score">0</div>
  <button id="click-btn">START</button>
  <div id="leaderboard"><h3>Loading leaderboard...</h3></div>

  <script>
    // === Splintr Bridge (inline for this minimal example) ===
    function splintrRequest(action, data = {}) {
      return new Promise((resolve, reject) => {
        const requestId = crypto.randomUUID();
        const handler = (event) => {
          if (event.source !== window.parent) return;
          if (event.data.requestId !== requestId) return;
          window.removeEventListener('message', handler);
          if (event.data.error) reject(new Error(event.data.error));
          else if (event.data.result && event.data.result.error) reject(new Error(event.data.result.error));
          else resolve(event.data.result);
        };
        window.addEventListener('message', handler);
        window.parent.postMessage({
          type: 'splintr_plugin_request', requestId, action, data
        }, '*');
        setTimeout(() => {
          window.removeEventListener('message', handler);
          reject(new Error('Timeout'));
        }, 10000);
      });
    }

    const Splintr = {
      submitScore: (score, meta) => splintrRequest('score.submit', { score, meta }),
      getLeaderboard: (limit) => splintrRequest('score.list', { limit }),
    };

    // === Game Logic ===
    let clicks = 0;
    let gameActive = false;
    let timeLeft = 10;
    let timer = null;
    const btn = document.getElementById('click-btn');
    const scoreEl = document.getElementById('score');
    const timerEl = document.getElementById('timer');

    btn.addEventListener('click', () => {
      if (!gameActive) {
        startGame();
      } else {
        clicks++;
        scoreEl.textContent = clicks;
      }
    });

    function startGame() {
      clicks = 0; timeLeft = 10; gameActive = true;
      scoreEl.textContent = '0';
      btn.textContent = 'CLICK!';
      timer = setInterval(() => {
        timeLeft -= 0.1;
        timerEl.textContent = timeLeft.toFixed(1) + 's';
        if (timeLeft <= 0) endGame();
      }, 100);
    }

    async function endGame() {
      clearInterval(timer);
      gameActive = false;
      btn.textContent = 'PLAY AGAIN';
      timerEl.textContent = 'Done!';
      try {
        const result = await Splintr.submitScore(clicks, { time: 10 });
        if (result.is_personal_best) {
          scoreEl.textContent = clicks + ' 🎉 New best!';
        }
        loadLeaderboard();
      } catch (e) {
        console.error('Score submit failed:', e);
      }
    }

    async function loadLeaderboard() {
      try {
        const data = await Splintr.getLeaderboard(10);
        const lb = document.getElementById('leaderboard');
        lb.textContent = '';
        const heading = document.createElement('h3');
        heading.textContent = 'Top Scores';
        lb.appendChild(heading);
        data.entries.forEach(e => {
          const row = document.createElement('div');
          row.className = 'lb-entry';
          const nameSpan = document.createElement('span');
          nameSpan.textContent = '#' + e.rank + ' ' + e.display_name + (e.node ? ' (' + e.node + ')' : '');
          const scoreSpan = document.createElement('span');
          scoreSpan.textContent = e.score + ' clicks';
          row.appendChild(nameSpan);
          row.appendChild(scoreSpan);
          lb.appendChild(row);
        });
      } catch (e) {
        console.error('Leaderboard load failed:', e);
      }
    }

    // Load leaderboard on startup
    loadLeaderboard();
  </script>
</body>
</html>



Reference Plugins


PluginShows
Breakout 1.0.4feed.request_post after a new personal best, with a long timeout and the button hidden for visitors
Alchemy Lab 1.1.0Local leaderboard merged with score.friends (one row per node, highest score); milestone feed posts remembered in a versioned save
Mahjong Solitaire, Minesweeper, Pachinko 1.1.0FriendsBoard This server / Friends tabs (js/leaderboard.js), per-difficulty and per-layout boards, feed posts on clears and new bests
Chess, Checkers, Connect Four 1.1.0Turn-based matches through the shared js/online.js (TurnGames): Your games list, friend picker, draw offers, nudges, rematches
Sketch & Guess 1.0.2Turn-based matches with secrets (match.guess / match.reveal), direct invites by target_node with a friend picker, polling match.list before match.get

Game Recommendations for Testing


When validating this plugin system, build these test plugins in order:


  1. Blackjack (single-player) — exercises user_info, data_user, leaderboard, theme awareness, and mobile responsiveness. Validates the full pipeline end-to-end without multiplayer complexity.

  1. Minesweeper (single-player) — exercises per-user stats tracking, difficulty-weighted leaderboards, and more complex game state. Validates data persistence across sessions.

  1. Connect Four (2-player) — exercises matchmaking, data_shared, and events. Supports both the sidebar invite flow (friends) and open lobby (public). Moves are communicated via events.publish/events.poll on a game_ROOMCODE channel. Tests the full multiplayer pipeline on a single server.