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

@squidhub/game-sdk

v0.5.1

Published

Official SDK for games published on SquidHub — saves, achievements, leaderboards, coins and multiplayer.

Readme

@squidhub/game-sdk

The SDK every game on SquidHub integrates against — cloud saves, analytics, and the coin economy.

Zero runtime dependencies. Works with hand-written JS/TS, Godot HTML5 exports, and Unity WebGL — Unity projects should install the com.robotsquid.squidhub package rather than this one.

npm install @squidhub/game-sdk

How it works

Your game runs in an iframe on a different registrable domain from the portal. That separation is the security model, not a deployment detail: it is what stops the browser attaching a player's session cookie to your game's requests.

So the SDK never talks to an API. It performs a handshake with the portal over postMessage, receives a private MessagePort, and every call rides that port. The portal brokers each one against the capabilities your game was granted at review.

your game  ──hello (nonce)──▶  portal  ──▶  platform
   iframe   ◀──ready + port──   page
            ◀═══ MessagePort ═══▶

Two consequences worth knowing up front:

  • A game only works when the portal launches it. The launch nonce arrives in the URL fragment (#s=…), so opening index.html directly, or reloading without the fragment, fails the handshake. That is intended.
  • The SDK verifies who embedded it against a compiled-in allowlist before sending anything. It will not hand the nonce to an unknown page.

Quickstart

import { connect, SquidHubError, SaveConflictError } from "@squidhub/game-sdk";

const sh = await connect();

console.log(`Hello ${sh.session.displayName}`);

// Saves are opaque bytes. The platform never looks inside them.
const save = await sh.saves.get();
if (save) restoreFrom(save.data);

sh.analytics.track("level_started", { level: 3 });

connect() resolves once the portal has verified the launch nonce. Call it once, at startup.

await connect({
  handshakeTimeoutMs: 15_000,  // how long to wait for the portal
  requestTimeoutMs:   20_000,  // per-call timeout
  helloRetryMs:          150,  // how often to re-send the handshake
});

The portal attaches its message listener after its own JavaScript hydrates, while your game is in the initial HTML and starts loading immediately. A small, well-cached build can post its hello before anyone is listening.

⚠️ This fails worse the faster your game loads, so it hits returning players with a warm cache and misses cold-cache testing entirely. Leave helloRetryMs alone unless you have a specific reason; raising it reintroduces the bug for exactly the players with the fastest connections.

The session

sh.session      // SessionView, available synchronously once connected
sh.connected    // false after close()
sh.can("saves") // was this capability granted?
sh.close()      // release the port

| Field | | |---|---| | gameUserId | ⚠️ A per-game pseudonym. The same player has a different id in your other games, and it is never the platform's user id. Stable for this (player, game) pair forever — safe as a save key | | displayName | For display. Do not use it as an identifier | | locale | BCP-47, e.g. en-GB | | capabilities | What this game was granted — see Capabilities | | entitlements | SKUs the player already owns | | coinBalance | ⚠️ Display only. Never gate a purchase on it; the server decides | | sdkVersion | The SDK build the portal saw |

Saves

const record = await sh.saves.get();       // SaveRecord | null
await sh.saves.put(bytes, { ifVersion });

One save blob per game — there are no slots. data is a Uint8Array and the platform stores bytes without ever parsing them, so use whatever format you like. If your game needs several save files, put them inside your own blob: you can interpret its structure and we deliberately cannot.

Ceiling: 8 MiB total (MAX_SAVE_BYTES). Your game's manifest may set something lower. Oversized writes are rejected client-side, before the round trip.

Conflicts are yours to resolve

Pass ifVersion from the last read to get optimistic concurrency. On a clash the SDK throws SaveConflictError carrying the server's copy, so you can merge without a second round trip:

try {
  await sh.saves.put(0, bytes, { ifVersion: save.version });
} catch (err) {
  if (err instanceof SaveConflictError) {
    const merged = mergeSaves(mine, err.serverCopy.data);
    await sh.saves.put(0, merged, { ifVersion: err.serverCopy.version });
  } else {
    throw err;
  }
}

⚠️ The platform never merges for you. Only your game understands its own save format. A game that ignores conflicts and writes unconditionally will lose a player's progress the first time they play on two devices.

Analytics

sh.analytics.track("boss_defeated", { attempt: 4, difficulty: "hard" });

Fire-and-forget: it returns nothing, is never awaited internally, and failures are swallowed so telemetry can never stall or break your game.

⚠️ Property keys must be declared in your manifest's analyticsProps. Anything else is dropped server-side and counted against you. Values must be string, number or boolean — no free text, which keeps player data out by construction.

Commerce

const { txnId, balance } = await sh.commerce.purchase("hat_gold", idempotencyKey);

⚠️ There is no price parameter, deliberately. The server resolves the price from the reviewed catalog, so a tampered client can ask to buy anything but cannot change what it costs.

⚠️ The confirmation dialog is drawn by the portal, outside your iframe. You can render a convincing "Spend 500 coins?" on your own canvas, but a spend only happens when the real overlay is clicked.

idempotencyKey must be stable for a given intent — derive it from what the player is buying, not from a timestamp. Retrying with the same key returns the original result rather than charging twice.

Sound

The portal draws a mute button next to the game. You do not have to do anything — the SDK interposes a gain node on your AudioContext's destination, so muting works even if your game has never heard of this feature.

⚠️ The portal cannot mute you by itself. A cross-origin iframe has no muted property, so the SDK inside your frame is what actually applies it. That means muting only works on a build made with SDK ≥ 0.5.0.

⚠️ Declare "sound": true in your manifest or the button will not appear. A game that makes no sound should not carry a mute control, and neither should a build too old to honour it.

If your game has its own mixer, honour the setting properly:

import { onMuteChange, isMuted } from "@squidhub/game-sdk";

onMuteChange((muted) => {
  myMixer.setVolume(muted ? 0 : 1);
});

⚠️ onMuteChange fires immediately with the current value, not only on later changes. The portal sends the player's preference the moment it hands you the port — usually before your handler is registered — so a change-only callback would miss a player who arrived already muted, and you would greet them with the one sound they asked not to hear.

⚠️ Your handler is an addition, not a replacement. The gain node still applies, so a handler that throws or forgets a channel cannot leave the player unmuted.

⚠️ Load the SDK before your engine boots. The patch only catches contexts created after it runs. The Godot instructions already require this for CSP reasons; the same ordering is what makes muting work.

Errors

Every failing call throws SquidHubError with a code:

| Code | Meaning | |---|---| | UNTRUSTED_EMBEDDER | Not launched by the portal, or no referrer. See Troubleshooting | | TIMEOUT | The portal did not answer in time | | NOT_CONNECTED | Called after close(), or before connect() resolved | | DENIED | Capability not granted for this game | | INVALID | Malformed payload | | CONFLICT | Save version moved on — thrown as SaveConflictError | | NOT_FOUND | No such SKU or key | | INSUFFICIENT_FUNDS | Not enough coins | | RATE_LIMITED | Too many calls. Carries retryAfter in seconds | | UNAVAILABLE | The platform cannot serve this right now | | PROTOCOL | Version mismatch or a malformed reply |

catch (err) {
  if (err instanceof SquidHubError && err.code === "RATE_LIMITED") {
    await wait(err.retryAfter * 1000);
  }
}

⚠️ Never block gameplay on a platform call. Treat every verb as something that can fail, and keep a local fallback — a game that will not start because a save fetch failed is a game that will not start.

Engine integration

Game engines cannot consume npm, so the package ships wrappers under engine/. They contain no protocol: they marshal to window.SquidHub, which engine/squidhub-bridge.js installs.

That file is prebuilt — copy it next to your export and load it with a <script> tag. You need no JavaScript toolchain, which is the point: a Godot or Unity publisher has no reason to have one. engine/bridge-entry.js is the readable source it is built from, if you would rather bundle it yourself.

Godot

1. Copy engine/squidhub-bridge.js next to your exported index.html. That is the whole of this step — it is prebuilt, and it installs window.SquidHub.

⚠️ Load it before Godot's bootstrap. It patches AudioContext.destination on the instance, so it only catches contexts created after it runs — land it later and the portal's mute button is inert.

2. Copy engine/squidhub.gd into your project:

var sh := SquidHub.new()
add_child(sh)
await sh.ready_signal                       # or connect_failed

var save := await sh.saves_get()            # {} when nothing is saved yet
if not save.is_empty():
    restore(save["data"])                   # PackedByteArray

sh.analytics_track("level_done", {"level": 3})

var res := await sh.saves_put(bytes, save.get("version"))
if res.is_empty():
    push_error(sh.last_error)               # {"code": …, "message": …}

Binary crosses the boundary as base64 and is decoded for you — data reaches GDScript as a PackedByteArray, and the bytes the platform stores are identical to those a hand-written JS build would write.

3. Uncheck "Thread Support" in the Web export preset. See Hosting constraints.

4. ⚠️ Use a custom HTML shell — the stock one will not run. SquidHub serves game builds under a CSP with no 'unsafe-inline', and Godot's exported index.html inlines its bootstrap. The failure is silent: the page loads, all the markup is there, nothing runs, and the only signal is a CSP violation in the console.

Carry the exporter's config as data (CSP does not govern a script block that never executes) and move the bootstrap into an external file:

<script type="application/json" id="godot-config">$GODOT_CONFIG</script>
<script src="squidhub-bridge.js"></script>
<script src="boot.js"></script>
// boot.js — external, so CSP permits it
const config = JSON.parse(document.getElementById("godot-config").textContent);
new Engine(config).startGame({ /* … */ });

Unity

Do not copy these files in by hand — install the package. Add the registry to Packages/manifest.json:

{
  "scopedRegistries": [
    { "name": "RobotSquid", "url": "https://registry.npmjs.org", "scopes": ["com.robotsquid"] }
  ]
}

then Window > Package Manager > My Registries > SquidHub > Install. It ships SquidHub.cs, SquidHub.jslib and a prebuilt bridge, and on a WebGL build it injects the bridge and hoists every inline script out of index.html for you — so the CSP failure below never happens. There is no template to choose and no bundling step.

await Sdk.WaitForReadyAsync();
var json = await Sdk.SavesGetAsync();      // response as JSON, for your own parser
Sdk.AnalyticsTrack("level_done");

Doing it by hand instead: engine/SquidHub.cs plus engine/SquidHub.jslib in Assets/Plugins/WebGL/. ⚠️ Both halves are required — [DllImport("__Internal")] does not link without the .jslib. Build without pthreads, copy engine/squidhub-bridge.js into the export, and use a custom WebGL template built the way the Godot one above is.

✅ The Unity path is verified. A real WebGL build completed a handshake, read a save and had an ungranted verb correctly refused, on 2026-09-04. The JavaScript path is tested too. ⚠️ Godot remains unverified — no Godot export has ever been through the pipeline.

Hosting constraints

These are properties of the environment your build runs in. All three bite at load time, and all three fail quietly.

No SharedArrayBuffer. Cross-origin isolation would disable the ad stack the platform runs on, so it is off by default. Godot: uncheck Thread Support. Unity: no pthreads. If your game genuinely needs threads, ask — there is an isolated tier, and it comes with no ads.

No inline scripts, no external resources. The CSP is roughly default-src 'self' blob: data: with script-src 'self' 'wasm-unsafe-eval' blob:. Every script must be an external file. No CDN font, no analytics snippet, no remote icon host — they are blocked at load and appear as a console error rather than a missing style.

Content-addressed, immutable URLs. Your build is served from a hash path with a one-year cache lifetime. Never fetch a build asset with a cache-busting query string; ship a new version instead.

Your frame is whatever shape you declared. Declare a display block (below) and the portal sizes the frame for you: inside your aspect window you get the whole box, outside it you are letterboxed rather than stretched.

⚠️ Do not implement your own orientation lock or "please rotate" screen. Declare the orientations you support and the portal draws that prompt itself, outside your frame. Two of them fight, and yours is the one the player sees first. Do not call screen.orientation.lock() either — it needs fullscreen on Android and throws on iOS.

⚠️ Your game keeps running behind the rotate prompt. There is no pause message today. If losing a few seconds matters, pause on visibilitychange and on a resize that takes you to an orientation you did not declare.

⚠️ Editing manifest.json alone changes nothing. It is excluded from the build hash, so a manifest-only re-upload registers no new version and your edit is discarded — you will get a warning saying so. Change a real file and rebuild.

Capabilities

Your game's server-side manifest declares what it wants; the platform grants what review approves; the broker enforces the grant. A verb outside your granted capabilities returns DENIED before it reaches any API.

{
  "gameId": "your-slug",
  "engine": "godot",
  "entry": "index.html",
  "requiresThreads": false,
  "capabilities": ["saves", "analytics"],
  "analyticsProps": ["level", "difficulty", "durationMs"],
  "saveSchemaVersion": 1,

  // Declares that this build makes sound and honours the portal's mute button.
  // Without it, no mute control is shown for your game.
  "sound": true,

  // Optional. Omit the whole block and your game fills whatever box it is given,
  // in any orientation, with no fullscreen button — which is what every game did
  // before this existed.
  "display": {
    // Which way a PHONE may be held. Ignored on desktop, where the aspect window
    // below does the work. Default: both.
    "orientations": ["landscape"],

    // The range of width:height your game plays well at. Inside it you get the
    // whole box; outside it the frame is letterboxed to the nearest bound.
    // Either side may be omitted for "no limit that way"; set both to the same
    // value for a fixed canvas. Default: no limit at all.
    "aspect": { "min": "4:3", "max": "21:9" },

    // Whether the portal offers a fullscreen button. Default: false. The portal
    // expands its own shell — your frame is never granted fullscreen itself.
    "fullscreen": true
  }
}

⚠️ A display block that does not parse is refused, not ignored — an unknown orientation or a ratio written 16/9 fails the upload with a message naming the field. Ignoring it would leave the default, which is both orientations, so a typo in a landscape-only game would quietly widen it.

New third-party games default to saves + analytics. Anything more — commerce, leaderboards, multiplayer — is granted at review. Use sh.can("commerce.spend") rather than assuming.

What works today

⚠️ The platform is mid-build. The protocol is settled and the SDK is stable, but not every verb is served yet.

⚠️ Handle SaveConflictError. It is not an edge case — it is what happens every time a player has your game open in two places, which is the situation cloud saves exist for. The server's copy is attached, and merging it is yours: the platform stores opaque bytes and cannot merge for you.

| Verb | Status | |---|---| | connect() / handshake / session | ✅ Live | | analytics.track | ✅ Live | | saves.get / saves.put | ✅ Live. Blobs in R2, optimistic concurrency, 8 MiB total per game | | commerce.purchase | ⚠️ Returns UNSUPPORTED — the coin economy is not wired up yet |

Write against the full API — the shapes will not change — but do not ship a game that cannot start when a save call fails.

Troubleshooting

Every failure here is quiet. None of them throws anything you would notice without looking.

UNTRUSTED_EMBEDDER: no referrer The game was opened directly rather than launched by the portal. Expected during local development — the SDK cannot verify an embedder that is not the portal, and will not send the launch nonce to one.

PROTOCOL: missing launch nonce The nonce arrives in the URL fragment (#s=…). ⚠️ If your game uses hash-based routing, read it before you touch location.hash — rewriting the fragment before connect() runs destroys the nonce, and the symptom is a game that works on first load and fails after any navigation.

The handshake times out after 15 seconds The bridge script never loaded, or the portal is not the embedder. Check the console for a CSP violation first — a blocked script produces exactly this.

Godot: "SquidHub bridge is not on the page" engine/squidhub-bridge.js was not copied next to your export, or the <script> tag loading it comes after Godot's bootstrap. If you bundled your own from bridge-entry.js, check it is IIFE and not ESM — a module script is deferred and runs after _ready has already looked for the global.

The page loads, all the markup is there, and nothing runs An inline <script> blocked by CSP. This is the most common first-integration failure with engine exports, and the only signal is a CSP violation in the console — see Godot for the fix.

An engine error mentioning SharedArrayBuffer or crossOriginIsolated Thread Support (Godot) or pthreads (Unity) is still enabled. See Hosting constraints.

A font, analytics snippet or remote image silently does not load default-src 'self' blob: data:. Everything your build needs must ship inside it.

Saves appear to fail for no reason They currently return UNAVAILABLE — see What works today.

Versioning

  • Additive changes (a new verb, a new optional field) keep the same major.
  • Any change to an existing verb's meaning means a new major, and the portal supports the current major plus at least one previous.
  • Retiring a major requires every game on it to be rebuilt, so for third parties it comes with a notice period measured in months.

PROTOCOL_VERSION is exported if you need to assert on it.

Support

Contact your SquidHub publisher representative. (⚠️ A public issue tracker and support address will be published before third-party onboarding opens — the source repository is private today, so it is not somewhere an outside studio can report anything.)

MIT licensed.