npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-sdk
import { 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 test

MIT 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.