@silicon-jungle/inkwell-sdk
v0.0.9
Published
Tiny modular SDK for games hosted on Inkwell.
Readme
Inkwell SDK
Player feedback and community links (0.0.8)
The game page and player header provide a feedback form without any game code. Only signed-in players can submit; creators review private feedback in Manage → Feedback and can keep, discard, or restore it while iterating with their tools.
import { Inkwell } from '@silicon-jungle/inkwell-sdk';
const game = await Inkwell.game.get();
// { slug, title, websiteUrl: string | null, discordUrl: string | null }
// Call from your own feedback button:
await Inkwell.feedback.open();feedback.open() opens the host-owned form and resolves to { opened: true }.
It does not submit feedback or confirm that a player submitted it. Guests see
sign-in; feedback text and the creator inbox are never exposed to game code.
Both modules require the Inkwell player frame and reject when it is unavailable.
Modular imports are game / get from /game and feedback / open from
/feedback. The root also exports GameInfo, getGame, and openFeedback.
Set optional links in Manage, through websiteUrl / discordUrl in the creator
API, or inkwell games update --game my-game --website https://example.com
--discord https://discord.gg/example. Omitting a field preserves it; null or
an empty value clears it. Website accepts HTTP(S); Discord requires an HTTPS
Discord invitation URL. Neither link is required to publish a game.
Negotiated binary events (0.0.7)
JSON events and actions remain compatible with existing games. For compact game
packets, a backend can opt in with defineBackend({ binaryEvents: true,
binaryMessages: { motion(bytes, connection, context, delivery) { /* validate */ } } }).
After connecting, call await connection.negotiateBinaryEvents() before selecting
the binary game protocol. It returns false for an older or opted-out server, or
after its default 2-second negotiation timeout; connection and transport failures
reject. The optional timeoutMs must be positive and at most 60 seconds. A result
is cached for that connection; reconnect to renegotiate.
Both connection types expose binaryEvents, sendBinaryReliable(name, bytes)
and sendBinaryUnreliable(name, bytes). Clients subscribe with
onBinary(name, (bytes, delivery) => {}), which returns an unsubscribe function.
Binary handlers are separate from JSON on/messages handlers. The sender copies
the Uint8Array view into its frame. Reliable sends return a promise; unreliable
sends return whether the local transport accepted the frame, not a delivery ACK.
Sending binary before negotiation throws. Datagrams that overtake the negotiation
response are discarded; reliable binary requires completed negotiation.
The @silicon-jungle/inkwell-sdk/wire export binaryEventOverhead(name) returns
the frame overhead: 5 bytes plus the validated ASCII event-name length. Subtract
it from connection.capabilities.maxUnreliableFrameBytes before packing payloads.
The complete frame remains limited to 1,200 bytes for unreliable delivery (or the
smaller negotiated transport limit) and 64 KiB for reliable delivery. The wire
format is IBE, version byte 1, one byte of name length, ASCII name, then raw
payload. encodeBinaryEvent and decodeFrame support that format; the latter
returns kind: 'event.binary' with a copied Uint8Array payload.
inkwell.binary.negotiate is a reserved reliable action. Binary encoding adds no
delivery guarantee or game schema validation: keep input authority on the server,
validate fields, and use explicit baseline/dictionary acknowledgements when a
packet depends on prior game state. Existing JSON actions stay JSON.
The tiny, optional SDK for browser games hosted on Inkwell. Your game remains a normal static web app: relative asset URLs, fetch, Three.js loaders, and PixiJS Assets all work without this package.
Install the public package from npm:
npm install @silicon-jungle/inkwell-sdkimport { Inkwell } from "@silicon-jungle/inkwell-sdk";
await startGame();
Inkwell.ready();
const player = await Inkwell.player.get();
// { displayName: 'james', avatarUrl: '...', isGuest: false }
const online = await Inkwell.presence.get();
// { total, guestCount, players, friends }
const stopMonitoring = Inkwell.performance.start();
// When the player reaches your definition of completion:
Inkwell.session.complete();
stopMonitoring();Modular imports
import { ready } from "@silicon-jungle/inkwell-sdk/core";
import { complete } from "@silicon-jungle/inkwell-sdk/session";
import { track } from "@silicon-jungle/inkwell-sdk/analytics";
import { trackedFetch, defaultTracker } from "@silicon-jungle/inkwell-sdk/assets";
import { get as getPlayer } from "@silicon-jungle/inkwell-sdk/player";
import { get as getPresence } from "@silicon-jungle/inkwell-sdk/presence";
import { start as startPerformanceMonitoring } from "@silicon-jungle/inkwell-sdk/performance";
import { connectBackend, requestBackend } from "@silicon-jungle/inkwell-sdk/backend";Creator backends
Use one logical on-demand backend for a room, small persistent world,
matchmaking, occasional API work, or a combination. Realtime connections prefer
WebTransport reliable streams plus unreliable QUIC datagrams. When the platform
provides a direct endpoint, the SDK verifies its certificate fingerprints and
connects straight to the game server without a WebSocket fallback. Those
connections report capabilities.unreliable === "native". Legacy gateway
connections can fall back to WebSocket and report "emulated" because their
runtime hop uses TCP. The default connection timeout is 60 seconds to allow
an idle server to start.
const connection = await Inkwell.backend.connect();
await connection.sendReliable("chat.send", { text: "hello" });
connection.sendUnreliable("player.input", { x: 1, y: 0 });
const response = await Inkwell.backend.request("/inventory/save", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ slots }),
});Server code exports defineBackend(...) from the /server entry point. Its
fetch handler receives the authenticated, bounded player identity as the third
argument. Browser request/response bodies are capped at 1 MiB.
Assets and loading bars
Keep using normal relative URLs such as ./assets/level.glb. Inkwell serves them from the game build's immutable isolated origin. Existing engine loaders are the best default.
For a byte-aware loading bar, use trackedFetch; for Three.js, PixiJS, or another loader, wrap its promise with trackPromise or report progress through an AssetTracker.
const unsubscribe = defaultTracker.subscribe(({ ratio }) => {
loadingBar.value = ratio ?? 0;
});
const response = await trackedFetch("./assets/level.glb");
const level = await response.arrayBuffer();
unsubscribe();Analytics
ready() and session.complete() power Inkwell's default creator-only play analytics. Small custom events are opt-in:
Inkwell.analytics.track("level.complete", { level: 3, score: 1200 });The SDK sends messages only to the trusted Inkwell player parent. It does not collect raw IP addresses, advertising identifiers, or cross-site tracking data.
Performance monitoring
Performance monitoring is opt-in. Inkwell.performance.start() sends one bounded aggregate sample every 30 seconds: FPS, p95/max frame time, long-task count and duration, visibility, and JS heap usage where the browser exposes it. It does not send raw traces, resource URLs, hardware details, or fingerprints.
Leaderboards
Optional module (requires the platform game-services release):
import { leaderboards } from '@silicon-jungle/inkwell-sdk/leaderboards'
const board = leaderboards.board('highscores')
await board.submit({ score: 12500, details: [3], method: 'keepBest' })
const top = await board.list({ scope: 'global', start: 1, limit: 20 })
const friends = await board.list({ scope: 'friends' })
const nearby = await board.aroundMe({ before: 5, after: 5 })
const mine = await board.getMyEntry()Inkwell.leaderboards exposes the same browser API. Create boards in Manage
games, through the creator API, or the hosted backend context's
leaderboards.define(). Backend boards expose submitFor(username, submission),
queryFor(username, query), update(definition), reset(),
deleteEntry(username), and delete().
createRuntimeServices() also supplies backend leaderboards outside the hosted
backend context. Runtime credentials must stay on the server.
Scores and up to 64 optional detail values are signed int32. Boards support
ascending/descending ordering, numeric/seconds/milliseconds display, backend-only
writes, and friends-only client reads. There are no replay attachments.
Queries return { board, total, entries }; entries expose username, avatar, rank,
score, details and update time, never private account data. Signed-in accounts
are required for score persistence. Submissions are limited to 10 per
player/game per fixed ten-minute window. Send results, not per-frame scores.
Achievements and stats
These optional modules require the game-services platform release.
import { achievements } from '@silicon-jungle/inkwell-sdk/achievements'
import { stats } from '@silicon-jungle/inkwell-sdk/stats'
await achievements.unlock('first_win')
const other = await achievements.get('explorer', { game: 'another-game' })
if (other?.unlocked) await achievements.unlock('well_travelled')
const requestId = crypto.randomUUID() // reuse only when retrying this update
await stats.increment('coins', 1, { requestId })
await stats.updateAverage('points_per_second', 120, 30)Inkwell.achievements and Inkwell.stats expose the same APIs. Backend contexts
and createRuntimeServices() additionally provide definition management,
achievements.unlockFor(username, name) and stats.forPlayer(username).
Cross-game achievement reads require public, published games and never permit
cross-game writes. Hidden achievement details stay hidden until unlocked.
Use achievements.games({ query, after }) to discover public achievement-enabled
games (100 per page, nextCursor for continuation), summary({ game, username })
for { total, unlocked }, or count({ game }) for the enabled definition count.
percentages() orders achievements by unlock percentage and includes the current
player's unlocked state. stats.get(name, { username }) reads one stat without
scanning pages. Linked private stat values are not exposed through achievements
to other players or other games; mark the stat publicRead to share that progress.
Unlock status/dates remain available through permitted cross-game reads.
achievements.percentage(name, { game? }) directly reads one achievement's
completion percentage, signed-in player count and current-player unlock flag.
It returns null for unavailable or still-hidden achievements. Use
achievements.percentages({ game?, offset? }) for the paginated ranking.
Stats support integer/fractional/average values, bounds, increment-only writes, maximum changes, backend-only authority, and aggregated totals/daily history. Linked achievements unlock in the same transaction as a successful stat update. Browser games can subscribe to backend-driven changes:
const stop = Inkwell.stats.onChange(({ kind, names }) => {
// Re-read the relevant stats and redraw your UI.
// kind: 'updated', 'reset', or 'refresh'; names is empty for reset/refresh.
})
// Call stop() when your screen or game tears down.These are best-effort invalidation hints, not values or a durable event log.
Read initial state when starting; re-read on refresh after the account socket
connects/reconnects. Backend writes/resets notify only the affected account's
current game frame, never other games or DMs. The host invalidates cached stats
and linked achievement reads before forwarding the hint. Pending offline writes
remain queued and retain reset-epoch protection. Notification delivery failure
does not undo a committed write. onChange is browser-only (a no-op on a backend).
Repeated unlocks preserve the original date; repeated stat request IDs cannot
double-count. Await writes for persistence (or an explicit queued receipt when
offline support is enabled). achievements.clear(name) and stats.reset({ achievements: true })
support testing, respecting backend-only write restrictions.
stats.aggregate({ historyDays: 7 }) returns up to 100 aggregated stats per page
(nextOffset for continuation). Each has totalExact, a decimal string summing
recorded player contributions without losing digits when totals exceed JavaScript's
safe-integer range. total remains an approximate number for convenience.
History accepts 0–60 UTC days, today first, including zero-activity days; each
entry has day, approximate delta, and decimal-string deltaExact.
Use BigInt(totalExact) for integer stats, or a decimal library for fractional
stats; converting the string back to Number can lose precision. This preserves
aggregate precision, not precision already lost in individual floating-point
game values. History measures daily net changes, including resets, not snapshots
of each day's total. Omitting both historyDays and a date range returns no history.
To select particular stats or older history, use the same query in a browser, external backend SDK, or hosted backend context:
const result = await Inkwell.stats.aggregate({
names: ['coins', 'levels_completed'],
startDate: '2024-02-28',
endDate: '2024-03-01',
});names accepts 1–100 names, removes duplicates, and filters before pagination;
unknown or non-aggregated names are omitted. Results stay name-sorted, not in
requested-name order. Both dates are canonical UTC YYYY-MM-DD dates, inclusive,
with a maximum span of 60 days. Use either a date range or historyDays, not both.
Range history is newest first with zero-filled missing days. The range only
selects history: totalExact still represents the current lifetime contribution
total, not the sum over that range. These queries remain current-game scoped.
For aggregated stats, maxChange caps each upload's signed contribution to the
global total; the player's value still saves if it satisfies the other bounds.
For example, starting at a default of 1000 and uploading 1002 with maxChange: 5
saves 1002 for the player but contributes only 5 globally. Subsequent uploads
contribute their raw change, capped in either direction; repeating a value adds
nothing. Without aggregation, maxChange instead rejects excessive player-value
changes. Reset removes that player's recorded contribution in full (an explicit
correction, not another capped upload). Historic totals from before contribution
accounting are preserved, with caps applied to subsequent changes.
Leaderboard discovery and dynamic creation
const existing = await Inkwell.leaderboards.find('highscores') // null if absent
const daily = await Inkwell.leaderboards.findOrCreate({
name: 'daily:2026-09-04', sort: 'descending', display: 'numeric',
})
const players = await daily.getEntryCount()
const page = await daily.list({ start: 1, limit: 20 })
if (page.nextStart !== null) await daily.list({ start: page.nextStart, limit: 20 })Browser find-or-create requires sign-in and accepts only name/sort/display.
Existing settings are never changed, disabled boards stay disabled, and new
requests are capped at 20 per player/game per UTC day. Backend find-or-create
accepts full definitions and returns a management-capable board, like define.
Enabled boards are listed on game pages, using communityName or the API name.
Global/friends pagination uses nextStart even when blocked players leave gaps.
The entry count is a total, not a list of private player identities.
Optional offline progress
import { offline } from '@silicon-jungle/inkwell-sdk/offline'
await offline.enable() // Online, signed-in session required first.
const result = await stats.increment('coins', 1)
if (result.queued) showPendingStatUpdate() // On this device, not yet server-confirmed.
const { pending, failures } = await offline.status()
await offline.flush() // Reconnect also triggers automatic retries.Inkwell.offline exposes the same controls. The platform parent stores queued
stat writes and achievement unlocks in IndexedDB, partitioned by game/player,
so a new immutable build origin does not lose them. Enable again each play
session. Own-player reads may use cached responses tagged offline, cachedAt
and pendingWrites; they do not optimistically include pending mutations.
Cross-game/other-player reads, clears, resets and backend operations are never
queued. Resets invalidate older pending writes. Failed/expired submissions appear in
status().failures; no synthetic unlock is reported before server acceptance.
Limits: 1,000 writes / 512,000 serialized characters per game/player, seven-day
expiry, 50 recent failure records. Clearing browser data removes pending submissions.
disable() stops queueing/retries without deleting pending data. This module
does not cache game assets or make a fresh offline login possible.
Game saves and conflict resolution belong to the creator. This module is only
an opt-in retry queue for explicit stat writes and achievement unlocks, not a
game-save system or a “most progress wins” merger. set overwrites the current
value when accepted; increment applies a delta once per retry ID. Two devices
submitting independent deltas contribute both; two absolute sets retain normal
write ordering, subject to the stat's bounds and increment-only restrictions.
Creators choose what to submit and how to reconcile their own game state.
Atomic backend progress batches
const requestId = crypto.randomUUID() // retain this ID if retrying this operation
const result = await context.stats.batchFor('jungle', {
stats: [
{ name: 'coins', mode: 'increment', value: 3 },
{ name: 'accuracy', mode: 'average', value: 12, seconds: 2 },
],
achievements: [{ name: 'first-win', unlocked: true }],
}, { requestId })
// { stats: [{ name, value }], unlocked: ['first-win', ...], cleared: [], duplicate: false }Also available as context.stats.forPlayer(username).batch(changes, options)
and on external runtime services. This method is backend-only; it does not
expose arbitrary-player writes to embedded games. The normal game-scoped runtime
credential selects the game. There are no cross-game writes.
A batch accepts1–100 changes total within the16KiB API body limit. Each stat
and each achievement may appear only once; array order has no effect. Stat mode
defaults to set; increment and average use the same validation, averaging
and aggregate-contribution rules as individual writes. Achievement entries
require an explicit boolean unlocked. Setting it to false clears the award,
even when a stat in this batch would otherwise auto-unlock it. Clearing advances
the player's save epoch once for the entire batch, invalidating older queued saves.
All entries are validated and committed in one database transaction: a missing
definition, invalid value or SQL failure cannot leave siblings partially applied.
Use the same requestId and changes on retry. Reordered identical changes are
accepted; reusing the ID for different changes returns409. A successful retry
returns the original saved result with duplicate: true and does not reapply
increments, repeat clears or send new award notices. The original result is a
receipt, not a read of the player's current state (including after a reset).
An optional epoch rejects stale-generation writes; omitting it targets current
backend state. Already-committed receipts remain retrievable after an epoch change;
that retrieval never writes again. Query after reconnect; change/award notifications remain best-effort.
The browser offline queue does not queue backend batches.
Read-only stat schema
const page = await Inkwell.stats.schema() // { stats, nextOffset }
const coins = await Inkwell.stats.schema({ name: 'coins' })
// Fetch subsequent pages with { offset: page.nextOffset } until null.This returns complete stat definition metadata: name/title, numeric kind, default,
bounds, maxChange, increment-only/server-write/public-read flags, aggregation and
AVGRATE window. It never returns saved player values, internal IDs, or management
permissions. Schema is readable by guests with a valid game session;
publicRead controls other players' values, not definition metadata.
The response is name-sorted,100 per page, and an optional exact name filter is
applied before pagination. Missing names return an empty page. It is always
current-game scoped and is not cached by the optional offline module.
Backends expose the same context.stats.schema() and runtime-services method.
For achievement metadata, use achievements.list({ game?, locale?, offset? });
locked hidden achievements retain their existing redaction. Creator-only
stats.definitions() and mutation methods remain separate.
Progress notifications
Achievement progress can also be shown without persisting it:
await achievements.indicateProgress('collector', 3, 10)
const unsubscribe = achievements.onNotification(notice => {
// kind is 'unlocked' or 'progress'; this event comes from the current host.
updateGameUI(notice)
})Progress display does not change a stat or unlock an achievement. Saved awards
appear in the platform player header, not over the game. Backend code can use
context.achievements.indicateProgressFor(username, name, current, max).
Notifications are best-effort; query saved state after reconnecting. Call
unsubscribe() when your UI is disposed.
Subscribe separately when your UI needs to re-read saved achievement state:
const stopChanges = achievements.onChange(({ kind, names }) => {
// 'updated': named backend unlock/clear; 'reset': player stats reset;
// 'refresh': reconnect or a stat change that may affect achievement progress.
void refreshAchievementUI()
})These are best-effort hints for the current game/player, not authoritative
values or a durable event log. The host invalidates its offline read cache
before delivering a hint, while preserving pending writes. Empty names means
re-read the relevant achievement list. Register before loading your initial
state and query again after reconnect; missed hints do not undo committed
awards. This subscription is browser-only (a no-op on a backend). Call
stopChanges() when disposing the UI. Cross-game reads do not subscribe to
other games' updates.
Backend game presence
In a hosted backend, await context.presence.get() returns
{ total, guestCount, players }. The same method is available on
createRuntimeServices().presence for external game backends. It reads the
current game's live platform presence room, not just connections to one backend
process. Duplicate connections for the same game-scoped player count once.
players contains at most50 public profiles (playerId, username,
displayName, avatarUrl, isGuest); total still counts the whole room.
Expired/revoked sessions are excluded. There is no game selector, account ID,
email or viewer-specific friend list. Failed/malformed presence HTTP responses
reject rather than pretending the game has zero players. Sessions whose access
cannot be revalidated are excluded (fail closed). This is an on-demand snapshot, not
a recommendation to poll; use it when game logic needs the current roster.
Game chat (optional module)
import { chat } from '@silicon-jungle/inkwell-sdk/chat'
const channel = await chat.connect('game', {
onMessage: message => renderMessage(message),
onModeration: event => removeOrClearMessages(event),
})
await channel.send('Hello!', { author: { displayName: 'My character' } })
await channel.send('Party ready.', { recipients: [otherPlayerId] })
channel.close()The SDK renews channel access and reconnects with history catch-up automatically.
Use a stable id in send options when retrying a failed send. Displayed authors
are game-controlled, not verified human identities. Routing IDs come from
channel.playerId or backend connection.identity.playerId; directed messages
stay within this game and are also readable by its backend. Named channels are
joinable by players of the game, not private rooms merely because their names
are hard to guess. This API provides no platform DMs or account messaging.
Account blocks apply in either direction to live delivery and history using the
authenticated transport senderId, never the game's displayed author fields.
Backend messages use the backend sender identity: relayed player text remains
creator-controlled content. Guests have no account block list. This is access
filtering, not a claim of verified human authorship inside games.
onModeration also receives chat.visibility with checked senderIds and their
blockedSenderIds. The SDK removes hidden senders from connection.messages;
refresh your rendered list on that event. Checks happen on delivery/history,
reconnect and periodic idle sweeps. Unblocking permits future messages and
history; use connection.history(0) to reload older messages. Previously copied
game-owned UI/data cannot be remotely erased.
Hosted backend context.chat supports list, define, and channel(name) with
send, history, remove, clear, and delete. To subscribe from a backend,
use createRuntimeServices().chat.connect(name) from the /storage module.
Per game: 128 channels, 1,000 retained messages/removal markers, 24-hour history, 500 concurrent sockets. Per player/backend: four sockets. Messages allow 2,000 characters and 32 recipients. Player sends are limited to 20/minute across channels; backend sends to 240/minute. History pages have at most 100 messages. Removed messages cannot be resurrected by retrying the same ID while their removal marker is retained. See game chat documentation.
Development
npm install
npm run check
npm testMIT licensed.
Engine exports and startup
A standalone browser bundle is shipped at dist/inkwell.browser.js (also exported as /browser). Copy it into your export and load it before the game loader to expose window.Inkwell. The Godot and Unity packages do this during export. Loading the bundle does not mark the game ready.
Inkwell.loading.progress(0.4); // null for indeterminate progress
await startGame();
Inkwell.ready(); // after the game becomes interactive
// On a startup failure: Inkwell.loading.fail("Could not load the first level.");ready() and failure are terminal for this page load; later progress is ignored. Retry reloads the game. Only send player-readable failure text, up to 500 characters. The platform applies the timeout declared in the project config. Legacy exports can explicitly choose startup.mode: "compatible" to retain their own loading UI.
defineGameConfig from /config also accepts game, client.entrypoint, client.engine, client.capabilities.threads, and client.startup. Engine exports keep their own HTML shell. Thread support is opt-in and requires a compatible browser.
Persistent game data remains developer-defined: use the existing backend fetch handler, trusted handler identity, database, and object storage. The engine examples demonstrate this without introducing a platform save format or a separate saves API.
Backend HTTP save requests and responses support up to 8 MiB decoded payloads. Large snapshots should use object storage behind a small database revision pointer; database row limits remain independent. Games must update the SDK and run on a current creator runtime to use the larger budget. Reliable event/message limits are unchanged.
