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 adminsAll 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
| Field | Required | Description |
|---|---|---|
id | Yes | Unique identifier (lowercase, alphanumeric, hyphens). Must be unique on the server. |
guid | No* | 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. |
name | Yes | Display name shown to users. |
version | Yes | Semantic version string (major.minor.patch). |
description | Yes | Short description shown in the plugin listing. Also searched by the Games catalog search box. |
author | Yes | Author name or handle. |
category | Yes | Top-level kind of plugin. Use games for games. Admin may override this. See Categories and Genres below. |
genres | No | Array 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. |
icon | No | Relative path to a square icon image (PNG or SVG, recommended 128x128). Falls back to a default icon. |
entry | Yes | Relative path to the HTML entry point. |
min_players | No | Minimum players (default: 1). Positive integer. |
max_players | No | Maximum players (default: 1). Positive integer, at least min_players. Values > 1 indicate multiplayer support. |
permissions | Yes | Array of permission strings the plugin requires. See Permissions below. |
data_limits | No | Requested storage limits. Server enforces its own maximums — these are hints. |
theme_aware | No | If true, the plugin accepts theme color variables from the host. Default: false. |
mobile_friendly | No | Declares the plugin works on small screens. Default: false. |
checksums | Yes | SHA-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_channels | No* | Event channels friends on other servers may read, e.g. ["chronicle"]. 1–5 names. *Required for federated_events. |
multiplayer_mode | No | realtime (default behaviour) or turn_based. Turn-based games can use matches that span servers. |
turn_timeout_days | No | Turn-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
| Permission | Description |
|---|---|
user_info | Read the current user's display name, node address, and whether they are a federated visitor. Never exposes email, password, or private data. |
data_user | Save and load per-user data (only the current user's own data). |
data_shared | Read and write shared plugin data (game world state, multiplayer sessions, etc.). All plugin users can access this. |
leaderboard | Submit scores to and read from the plugin's leaderboard. |
events | Subscribe to and publish events on named channels (for multiplayer awareness). |
friends_list | Read the current user's friends list, here and on other servers (display names and nodes — for multiplayer matching and invites). |
matchmaking | Open lobby matchmaking (public games) and sidebar invites (private games with friends, including direct invites to one friend on another server). |
feed_post | Ask 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_scores | Read friends' best scores for this game from their home servers (score.friends). Needs guid and leaderboard. |
federated_events | Read events on your federated_channels from friends' home servers, read-only (events.friends). Needs guid and events. |
federated_turns | Turn-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:
| Channel | Shape | Typical 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:
| Mistake | What happens | Correct |
|---|---|---|
Waiting for a reply with type: 'splintr_plugin_response', or reading success / data | Every request times out; names show as "Guest" and leaderboards never load | Match on requestId only; read result and error |
Reading data.load results directly | Saved data never loads | The stored value is result.value (null if the key doesn't exist) |
Reading theme.get colors at the top level | Theme never applies | Colors are under result.colors |
Showing only display_name on leaderboards | Players with the same name are indistinguishable, especially across servers | Show 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 action | Use the exact action names in the API Actions Reference |
Using the default 10-second timeout for feed.request_post | The request "times out" while the player is still reading the post window, and your game says sharing failed | Allow up to 5 minutes (300000 ms) for feed.request_post |
| Waiting for a "bridge ready" message before sending requests | Requests are delayed; no such message exists | Send requests as soon as your script runs |
Wrapping inputs in a <form> and listening for submit | Pressing the button or Enter does nothing; no error in your code, only a console warning | Don'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
| Action | Permission | Section |
|---|---|---|
user.info | user_info | user.info |
data.save, data.load, data.delete, data.keys | data_user / data_shared (by scope) | data.* |
score.submit, score.list, score.my_best | leaderboard | score.* |
score.friends | federated_scores | score.friends |
events.subscribe, events.unsubscribe, events.publish, events.poll | events | events.* |
events.friends | federated_events | events.friends |
friends.list, friends.groups | friends_list | friends.list, friends.groups |
matchmaking.* | matchmaking | matchmaking.* |
match.* | federated_turns | Turn-Based Matches |
feed.request_post | feed_post | feed.request_post |
theme.get | none | theme.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:
| Value | Who sees the invite |
|---|---|
public | Anyone on this server. Use the open lobby for public games; public invites are never sent to other servers. |
friends | The inviter's friends, on this server and on other servers |
friend_group | Members of one of the inviter's own friend groups (requires friend_group_id), on this server and on other servers |
direct | One 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
- A player's browser polls at regular intervals (e.g., every 3-6 seconds) via
events.poll. - Your plugin checks the zone's shared state (for example a
last_ticktimestamp stored withdata.saveinsharedscope) to see whether a tick is due. - If one is due, that browser runs your tick logic, saves the new zone state, and publishes the results as events.
- 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_tickis 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_awareistrue, the host sends the theme colors in asplintr_theme_injectmessage 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.pyadds 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.
- No external network requests. The iframe's CSP blocks all outbound connections. All data goes through the
postMessagebridge. (Federated features work because the host page, not your plugin, talks to other servers.) - No access to the parent page. The iframe sandbox prevents DOM access, cookie access, and navigation of the parent frame.
- No
eval()ornew Function(). The CSP blocks dynamic code execution. Games do not need these — use regular functions, event handlers, and state objects. - No localStorage/sessionStorage. Use the Splintr data API instead — it persists across sessions and devices.
- 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. - 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
actionfield. - Use DOM methods, not innerHTML. Build UI elements with
document.createElement(),appendChild(), andtextContentinstead of settinginnerHTMLwith 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. - Key names are validated. Only alphanumeric characters, hyphens, underscores, colons, and periods. Max 200 characters.
- Data values are size-limited. The server enforces per-plugin limits. Default: 256KB per user key, 1MB per shared key, configurable by admin.
- 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.
- No
<form>submission. The iframe sandbox doesn't includeallow-forms, so submitting a<form>is silently blocked and itssubmithandler never runs. Inputs work normally: use atype="button"button with aclickhandler (plus an Enter-key listener on the input) and read the values in JavaScript, then save them withdata.save. - Treat data from other servers as untrusted. Entries from
score.friends, events fromevents.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 withtextContent.
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, andmatchmaking.*: 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:
categoryis 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}. Usegamesfor all games. The admin may override it at install time.genresare finer tags within the Games catalog, used for filtering. A game can have several (Blackjack is bothcardandcasino).
Categories
| Category | Description | URL pattern |
|---|---|---|
games | Games 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.
| Genre | Use for | Examples |
|---|---|---|
card | Games played with a deck of cards | Solitaire, Blackjack, Poker |
casino | Gambling-style games | Blackjack, Poker, slots |
arcade | Real-time action and reflex games | Breakout, Space Invaders, Coin Pusher |
puzzle | Logic and problem-solving | Minesweeper, Sudoku |
word | Spelling and vocabulary | Hangman, word search |
board | Board games | Connect Four, chess, checkers |
strategy | Planning-heavy games | Reversi, tower defense |
dice | Dice games | Yahtzee-style, backgammon |
trivia | Quiz and knowledge games | Trivia rounds |
party | Casual games for groups | Drawing, 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) })
});| Action | Data | What it does |
|---|---|---|
match.start | room_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.get | match_code | The 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_state | match_code, state, secret | Saves state without passing the turn. Only on your turn. Counts as activity. |
match.end_turn | match_code, state, next_seat, secret | Saves state, passes the turn (to next_seat, or the other player) and sends them "Your turn". |
match.nudge | match_code | Reminds the player whose turn it is. Once every 4 hours. |
match.quit | match_code | Leave the match; it ends as a forfeit. |
match.finish | match_code, winner_seat (null = draw), state | Ends the match on your final move. |
match.guess | match_code, guess | On 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.reveal | match_code | Give up: on your turn, returns { answer, tries } for the other seat's secret. |
match.rematch | match_code, state | After 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) withmatch.save_stateormatch.end_turnto 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 withmatch.guessor give up withmatch.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.getormatch.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 callmatch.getwhen 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: trueorfalse. 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.infosaysis_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
guidand 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_youmarks 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: trueandretry_after(seconds), so you can disable a Refresh button until then. metais 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 usetextContent.- 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.listas 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.listby node), plusscore.friends, plus the player's own score (their local best, andis_youentries 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 whenfailed_serversisn'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 resolvesIt 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_channelsare 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: []withretry_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.
datacomes from another server. Validate it and render it withtextContent.- 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.htmlare relative - [ ] No external URLs (CDNs, APIs, remote images)
- [ ] No
eval(),new Function(), or dynamic code generation - [ ]
manifest.jsonhas all required fields - [ ] Games use
"category": "games"and list theirgenresfrom the fixed genre list - [ ]
min_playersandmax_playersreflect every mode the game supports - [ ]
checksumsin 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
errorfields, including ones insideresult) - [ ] Bridge replies are matched on
requestIdand read fromresult/error(notype,successordatafields) - [ ] If
theme_awareistrue, the plugin listens forsplintr_theme_injectand its colors come from--splintr-*variables - [ ] Leaderboards show each player's
nodenext to theirdisplay_name - [ ] Federated features have a
guid, and data from other servers is validated and rendered withtextContent - [ ] Home-server-only features (feed posts, friends' scores and events, direct invites) are hidden for visitors
- [ ]
feed.request_postuses 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.mdexplains what the plugin does and any admin configuration needed - [ ] No
<form>elements; buttons aretype="button"with click handlers - [ ] The game draws its own title, and
index.htmlincludes thesplintr-scrollbarsstyle 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_postorfederated_scoresis 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
guidlater 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
| Plugin | Shows |
|---|---|
| Breakout 1.0.4 | feed.request_post after a new personal best, with a long timeout and the button hidden for visitors |
| Alchemy Lab 1.1.0 | Local 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.0 | FriendsBoard 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.0 | Turn-based matches through the shared js/online.js (TurnGames): Your games list, friend picker, draw offers, nudges, rematches |
| Sketch & Guess 1.0.2 | Turn-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:
- Blackjack (single-player) — exercises
user_info,data_user,leaderboard, theme awareness, and mobile responsiveness. Validates the full pipeline end-to-end without multiplayer complexity.
- Minesweeper (single-player) — exercises per-user stats tracking, difficulty-weighted leaderboards, and more complex game state. Validates data persistence across sessions.
- Connect Four (2-player) — exercises
matchmaking,data_shared, andevents. Supports both the sidebar invite flow (friends) and open lobby (public). Moves are communicated viaevents.publish/events.pollon agame_ROOMCODEchannel. Tests the full multiplayer pipeline on a single server.