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

@hopple/sdk

v1.0.0-next.9

Published

The Hopple SDK: what a game imports to talk to the host that runs it, forwarded to the runtime its page loads.

Readme

@hopple/sdk: writing a game

@hopple/sdk is the library a game imports. It connects the game to whatever host is running it (the website, the app, or the dev shell) and gives the game everything it would otherwise have to build itself: saves, scores, achievements, input, sound settings, and so on.

This guide covers what a game can do and what it must do. For the manifest the game ships with, see the manifest's reference. To run and publish a game, see the hopple CLI. Every exported name and type is documented where it is declared, in the package's index.d.ts.

Contents

What a game is

A game is:

  • an HTML page, and
  • a manifest that describes it.

Both are named in the project's hopple config (hopple.config.ts, .js or .json): the built page as page, and the manifest itself as manifest. See the CLI. Build the page however you like. npx hopple create my-game sets up the recommended way (Vite, with the config in TypeScript); see hopple create.

The host loads the page into a frame sandboxed with only allow-scripts, allow-same-origin and allow-pointer-lock, every version on an origin of its own. That has some consequences you need to plan for:

  • Its output directory is published. hopple publish uploads the directory the page is in, with the page as its index.html, and it refuses a page that loads a file the directory doesn't hold. Refer to files relatively. A page beside its config is published alone.
  • No network. A game can't fetch, open a socket or load anything from another host. It may load its own files, and data: and blob: URLs. Anything the game could send out could carry what it knows about the player.
  • No localStorage, sessionStorage, IndexedDB, Cache Storage or cookies. Whatever a game stored would be cleared every time its page loads, so the runtime makes each of them throw an error naming Hopple.save, and hopple check refuses a game that uses them. Use sdk.save instead.
  • Every address relative, no <base>, and UTF-8 pages. hopple check refuses the rest.
  • No alert, confirm or prompt. They are blocked in the sandbox.
  • Sound may need a tap inside the game first. Browsers only allow audio after a user gesture in the frame. Sound routed through sdk.audio.output starts on the first press by itself; show a "tap to start sound" hint until then.

Getting started

The SDK is not part of your game. It is the runtime, which whatever runs the game serves beside it, and your page loads as its first script:

<script src="_rt/hopple.js"></script>

The runtime defines the global Hopple, holding everything this guide describes. Because it is served rather than built into the game, a fix to the SDK (input, on-screen controls, audio, layout, the loop, save) reaches every game without a rebuild. Which release a game gets is its track's: the major its manifest's api names, or next, built with the previews it declares. A change that would break games is a new major, which a game takes by changing its api. Keep the address relative, as above: Hopple serves a game at the root of its own origin, and a host like itch.io under a path, with _rt/ beside index.html either way.

  • With a bundler (Vite, esbuild, webpack, Rollup, Parcel, Bun): npm install @hopple/sdk and import it. The package is the SDK's types, with every doc comment, in node_modules/@hopple/sdk/index.d.ts, and a stub of a few lines that forwards each import to Hopple: your bundle carries the stub, never the SDK. Set your bundler to write relative addresses (Vite: base: './').
  • Without one: use the global Hopple directly (const sdk = await Hopple.init() in a module script or an async function).

hopple check, hopple publish and the server refuse a page that doesn't load the runtime first, or that carries a copy of the SDK.

A hopple.config.json can point its "$schema" at ./node_modules/hopple/config.schema.json for an editor to check it; a hopple.config.ts is checked by its type, import type { HoppleConfig } from 'hopple'. Both are the CLI's, which reads the config.

import { init } from '@hopple/sdk';
import type config from '../hopple.config.ts';

const sdk = await init<typeof config.manifest>();

// load assets, build the scene …

sdk.lifecycle.ready();   // "I can be shown now"

init() sends the hello and resolves with the SDK object once the host has answered and the page's window has taken the frame's size, so innerWidth and innerHeight are right from the game's next line. It rejects if there is no host, the host doesn't answer, or the host speaks an incompatible protocol.

The type argument is the game's manifest. With it, every name the game passes in — actions, on-screen layouts, score boards, achievements, analytics events and their properties, audio channels — is checked against what the manifest declares, so a typo is a compile error. Without it every name is a string.

Don't catch that rejection. A host never shows a game that didn't get through the handshake, so no player would see a message you drew. Let it through: it ends up in the game's console, with its stack, where you can read it while developing. The host records the failure on its own side.

Don't write the game to also run without a host either. That means every feature written twice (the SDK's input and your own key listeners, the lifecycle and visibilitychange, the host's volume and your own), and the half no host ever runs is the half that breaks. While developing, run the game with npx hopple dev.

Options for init(), all optional and rarely needed:

| Option | Meaning | |---|---| | onError | Called with problems the game should know about but that don't reject a call: a refused analytics event, a save value that JSON would silently change. Defaults to console.error. | | timeoutMs | How long each request waits for the host. Default 10 s. The handshake always waits at least 10 s, whatever this says. | | save | How to merge two saves made on two devices: { merge }. See Save. |

The host is told about your errors too: from the moment init() is called, the SDK reports every exception nothing caught, every promise rejection nothing handled, and everything it passes to onError, with its stack. That is how hopple dev can show what a game threw without anyone opening the frame's console. Nothing is suppressed: the console prints everything as before. An error thrown before init() is called, or a script that doesn't parse, can't be reported, so look in the browser console when a game never starts.

Everything else hangs off the returned object: sdk.lifecycle, sdk.layout, sdk.save, and so on. It also has sdk.ping() (resolves with the round-trip time in milliseconds) and sdk.dispose() (disconnects; calls still waiting reject with disposed).

When a feature isn't available

A host doesn't have to offer every feature. Each one a host may lack says so when init() resolves: sdk.save.available, sdk.score.available, sdk.achievements.available, sdk.share.available and sdk.panel.available. Leave out the buttons and screens of a feature that isn't there.

Calls come in two kinds:

  • Calls with an answer (save.read, save.write, save.clear, score.submit) return a promise. On a host without the feature — one that doesn't offer it, or one older than it — they reject with a BridgeError whose code is unsupported. Carry on; never crash on it.
  • Calls with nothing to answer (achievements.unlock, achievements.progress, share.offer, panel.open, lifecycle.ready) return nothing. On a host without the feature they do nothing, and anything else that goes wrong goes to onError. There is nothing to await, catch or check first.

State the host may not know yet

sdk.save.status, sdk.score.standing and sdk.achievements.standing are known at startup when the host has the answer to hand, and say { known: false } when it doesn't yet — a store still answering. Never draw "nothing yet" for known: false; each one's on() says when it becomes known.

Listeners

Every on(listener) hears a change of state and returns a function that removes the listener. Most are called with (now, previous); the host's orders with the new value (loop.on and resources.on with allowed, pause.on with paused), and audio.on with nothing: read volume() and allowed. Streams of events rather than state have their own names: input.onAction, input.onStick and input.onPointer.

Lifecycle: when to run

Phase is how far the game has got in starting up. It only moves forward:

| Phase | Meaning | |---|---| | loading | Connected, preparing. | | ready | The host heard sdk.lifecycle.ready(). Only the game can make this happen. | | disposed | About to be removed. Last chance to save. |

Everything else the host decides, and tells the game as one order per system. The game never learns why (swiped past, off screen, under an ad): it obeys each order on its own.

| Order | While false, the game… | Done for you by | |---|---|---| | sdk.audio.allowed | makes no sound. | audio.output, audio.media; audio.volume() is 0 | | sdk.loop.allowed | asks for no frames. | loop.tick | | sdk.resources.allowed | frees what it can load again. | nothing: what is expensive is the game's to know |

sdk.lifecycle.phase;   // 'loading' | 'ready' | 'disposed'

// The game loop, run only while the loop is allowed. dt is in seconds, capped
// at MAX_STEP, and 0 on the first frame after the loop starts or comes back.
sdk.loop.tick((dt) => {
  update(dt);
  draw();
});

// An engine with its own loop sleeps and wakes it itself.
sdk.loop.on((allowed) => (allowed ? engine.loop.wake() : engine.loop.sleep()));

sdk.resources.on(async (allowed) => {
  if (allowed) await reloadTextures();   // the host waits for this
  else unloadTextures();
});

All three are known as soon as init() resolves. A host may show the game while it is still loading, so a loading screen is worth drawing.

When an order changes, the host waits for the game's reply. If your listener returns a promise, the reply is sent when it settles, so the host knows you have actually let go. The host only waits about half a second, then carries on regardless.

Rules:

  • Stop your game loop when it isn't allowed. A game that keeps running while hidden drains the player's battery.
  • Pause when the host asks (sdk.pause.on): it asks when it takes a game in play away from the player, and leaves resuming to the player.
  • If the host doesn't hear ready() within 5 seconds of connecting, it assumes you're ready and shows you anyway.
  • A game that reloads itself starts again at loading; the orders stay what they were.

Layout: where to draw

The host draws its own buttons over or next to the game, and the device may have a notch or rounded corners. sdk.layout tells you where everything covering the frame is.

The frame is the page's own window, sized by the time init() resolves: size the game from innerWidth and innerHeight or with CSS, as any page does.

sdk.layout.frame;          // { width, height } of the frame, in CSS pixels: the window's size
sdk.layout.obstructions;   // what covers the frame: [{ rect, kind, reserved }]
sdk.layout.insets;         // { top, right, bottom, left }: the simple version
sdk.layout.on((now, previous) => relayout(now.insets));   // now and previous: frame, obstructions and insets

Each obstruction has:

| Field | Meaning | |---|---| | rect | { x, y, width, height }, in the frame's own coordinates. | | kind | chrome (the host's buttons), cutout (the device: a notch, a camera hole), overlay (the host's panel or a dialog over the game), or controls (the on-screen controls, while shown). New kinds may appear in future. | | reserved | true: that area belongs to the host, and taps there never reach you. false: something is on top of you but you are still running, and it's your choice whether to dim, move things or ignore it. |

Two ways to use it:

  • Simple: keep everything the player must touch inside sdk.layout.insets. This is always correct, but can waste space: a button in a corner becomes a whole strip along one edge.
  • Precise: read the reserved rectangles and avoid just those.
// Stay clear of the host's buttons and overlays, but draw behind the notch:
import { insetsFrom } from '@hopple/sdk';

const insets = insetsFrom(
  sdk.layout.obstructions.filter((o) => o.kind !== 'cutout'),
  sdk.layout.frame,
);

The insets are also CSS custom properties on the page's root, kept up to date before any on listener runs, for a game made of DOM elements:

#menu { padding: var(--hopple-inset-top) var(--hopple-inset-right) var(--hopple-inset-bottom) var(--hopple-inset-left); }

The manifest's layout.insets setting decides whether the host shrinks the frame to avoid its buttons (reserve, the default) or gives you the whole screen and relies on you to keep clear (draw-under). See the manifest's reference.

Info: facts about the player and device

A snapshot taken when the game connected. It never changes during a run.

sdk.info.app;      // { id, version, surface: 'app' | 'web' | 'dev' }
sdk.info.game;     // { id, version }: what the host believes it loaded
sdk.info.player;   // { id: string | null, returning: boolean, locale: string }
sdk.info.device;   // { tier: 'low' | 'mid' | 'high' }
  • device.tier is a rough measure of the device's power. Use it to pick asset quality or effect counts: const particles = { low: 50, mid: 200, high: 800 }[sdk.info.device.tier];
  • player.id is different for every game, so two games can't recognise the same player. null means the host has no notion of a player.
  • player.locale is the language the player chose in the host, which may differ from navigator.language.
  • app.surface is 'dev' only inside hopple dev. Use it for debug overlays, never for game logic.
  • game.version comes from the manifest the host read, not from your code. If it differs from the version your code expects, the wrong build was uploaded.

On a host too old to send info, you get safe defaults: surface web, tier mid, no player, locale en, empty ids.

Analytics

sdk.analytics.track('level.complete', { level: 3, won: true });   // name and property types checked against the manifest

track sends the event immediately and returns nothing. It never throws and never waits.

  • Declare every event in the manifest (see the manifest's reference). An undeclared name is refused. Properties are flat: strings, numbers and booleans only.
  • Refusals are reported to onError, so you find out during development. Your code doesn't need to handle them.
  • Hosts rate-limit events. Track things that happen (a level finished), not things that are happening (every frame of movement).
  • You don't need to track sessions, loading or placement changes. The host records those itself, under names starting with hopple..

Save

One save document per player per game. The host decides where it is stored and never looks inside it.

sdk.save.available;                           // false where this host keeps no saves
sdk.save.status;                              // what is stored, before you fetch it (see below)
sdk.save.on((now, previous) => redraw(now));  // status changed: a read, a write or a clear
const saved = await sdk.save.read();          // { version, state } | null
sdk.save.onChange((now, why) => restore(now)); // the save changed under the game: play on from this
await sdk.save.write(state, { version: 2 });  // resolves once the host says it is safely stored
await sdk.save.clear();
sdk.save.maxBytes;                            // the largest save this host accepts

sdk.save.status is available immediately, so you can show "Continue" or "New game" without waiting for a round trip. It has three possible values:

| Value | Meaning | |---|---| | { known: true, stored: { at, version, bytes } } | A save exists. at is when it was written. | | { known: true, stored: null } | The host checked: there is no save. | | { known: false } | The host hasn't said (it is still loading). Call read() to find out. |

It follows the game's own reads, writes and clears.

Two ways to load. A signed-in player's save comes from a server, which may take a moment:

  • Wait for it: await sdk.save.read() before the first frame. The host waits for the server (a few seconds at most, and not at all offline) and answers with the player's save.
  • Start at once: listen with onChange, call read() without awaiting it, and apply whatever either hands over. The game plays from nothing, or from this device's save, and takes the player's save when it comes.
const restore = (s: SaveState | null) => { progress = s ? (s.state as Progress) : fresh(); };
sdk.save.onChange(restore);      // before read()
sdk.save.read().then(restore);

Listen with onChange either way. The save can also change while the game plays: the server answered later than the host would wait, or a page left open hears that another device played on. onChange(save, why) hands the game the save to play on from then, and why:

| why | What happened | |---|---| | 'arrived' | Another device's save, built on what the game had. With a merge, merged with what the game last saved. | | 'merged' | Two saves that went their own ways, made one by the game's merge. | | 'gone' | Thrown away on another device, with nothing made here since. save is null. |

To keep the game's own instead (progress it hasn't saved yet, or a New game the player chose a moment before a save arrived), write it from the listener: it takes the handed save's place.

sdk.save.onChange((now, why) => {
  if (why === 'arrived' && justStartedNewGame) void sdk.save.write(progress);  // the New game wins
  else restore(now);
});

A game that does not listen is never written over: its next write is kept beside the save that arrived, for its merge or the player to settle.

How to use it:

  • Save as you go, not only at the end. Nothing saves on your behalf, and a game that waits until it is being removed may not finish in time.
  • There is no flush(). Each write is sent immediately; awaiting it is how you know it is stored.
  • version is your own save format number. It is stored next to the data, so you can check it before deciding whether to read an old save. There is no migration hook: write the if yourself.
  • Strings are stored as they are, and a Uint8Array as its bytes; anything else goes through JSON.stringify. Each comes back as it went in: a string, a Uint8Array, or the parsed value. So a Map, a Date or a typed array inside an object won't come back the way it went in. Inside hopple dev, the SDK warns (via onError) when you save something JSON would change.
  • Bytes pack a big save small. Write a Uint8Array with @hopple/bytes (varints, floats, strings, and flags a bit each), and it is stored and counted as those bytes.
  • Every host accepts at least 64 KiB (SAVE_BYTES), counted in UTF-8 for a string or JSON and in bytes for a Uint8Array. A larger save is rejected with too_large before it is sent.

Two devices. A signed-in player's save follows them to every device. When two devices each saved without seeing the other's progress (one was offline), the host keeps both and asks the game to merge them, with the merge passed to init():

const sdk = await init({
  save: {
    merge: (mine, theirs) => ({ version: 2, state: combine(mine.state, theirs.state) }),
  },
});
sdk.save.onChange((now) => restore(now)); // merged while playing: play on from this
  • merge(mine, theirs) is pure and answers with the save to keep in place of both. Throw where the two can't be merged, and the host asks the player which to keep, as it does for a game with no merge.
  • Asked while the game loads, the merge is what read() resolves with. Asked while it plays, it goes to onChange listeners, and waits for the next load where there are none.
  • A save that arrives while the game plays is merged with what the game last saved too, where it has a merge, before onChange hears it.
  • Inside hopple dev, the SDK warns (via onError) when the save changed while nothing listened with onChange.

| Error | Meaning | |---|---| | unsupported | This host has nowhere to store saves (available is false), or is too old to keep a Uint8Array. | | too_large | Over sdk.save.maxBytes. | | rate_limited | Saving too often, usually from inside the game loop. | | bad_params | The state can't be turned into JSON at all (a cycle, a BigInt). | | internal | Storage failed (a full disk, a network problem). |

Score

One number per run, submitted to a board declared in the manifest. The host draws the leaderboard; the game only ever sees the player's own numbers.

sdk.score.available;                                 // false where this host keeps no scores
sdk.score.boards;                                    // ['points', 'speed']
sdk.score.standing;                                  // { known: true, boards: { points: { best, at, runs, rank?, of? }, speed: null } }
                                                     // { known: false } until the host says
sdk.score.on((now, previous) => redraw(now));        // the host's answer arrived, or a submission moved it
const result = await sdk.score.submit(1200, { board: 'points' });
// { improved: true, best: 1200, rank?: 23, of?: 400 }
  • Submit every run, not only improvements. The host keeps every run and works out the best itself (which is what makes "best this week" possible). A worse run returns improved: false.
  • board can be left out if the game has exactly one board.
  • submit resolves once the score is stored on the host's side, which may be before it reaches a server. That is why rank and of are optional: only show a rank when you were given one.
  • standing has the same three answers as the save status: a board's standing, null (never scored on this board), or known: false (unknown right now). Don't show "no scores yet" for known: false.
  • There is no anti-cheat. The SDK only rejects values that aren't finite numbers in a safe integer range.

Errors: unsupported (this host keeps no scores), bad_params (a board the manifest doesn't declare), rate_limited (far more submissions than runs could explain).

Achievements

Declared in the manifest. The game says when one is earned or how far along the player is; the host draws the list.

sdk.achievements.available;                     // false where this host keeps none
sdk.achievements.standing;                      // { known: true, items: [{ id: 'first-ping', unlocked, at?, progress? }, …] }
                                                // { known: false, items: [{ id }, …] } until the host says
sdk.achievements.on((now, previous) => redraw(now));   // the host's answer arrived, or a report moved something
sdk.achievements.unlock('first-ping');          // returns nothing: the host announces it
sdk.achievements.progress('centurion', 40);
  • Progress is an absolute value, not an increment. progress(id, 40) means "the player is at 40". The host keeps the better of the old and new value, so reporting the same thing twice, or out of order, is harmless. If you need a running total, keep it in your save.
  • The host shows the banner, once, the first time something is earned. Reporting something already held is harmless, and reporting to a host without achievements does nothing: there is nothing to await, catch or check.
  • Calling unlock on a measured achievement completes it (for a shortcut or a cheat code); its progress is set to the target.
  • standing has three answers, like the score's: don't show known: false as "locked".

What the host refuses goes to onError: bad_params (an undeclared id, or progress on an achievement with no target), rate_limited.

Share

Asks the host to offer the player a link to this game, carrying some data of your own (such as a level seed). Opening that link starts the game with the same data. You can also say why the link was made, in meta: the platform keeps it with its record of the link, so you can later learn which of your shares bring players in.

sdk.share.offer({
  data: 'seed=42',                                 // handed back to whoever opens the link
  meta: { level: 3, from: 'results-screen' },      // kept by the platform, never handed back
});

sdk.share.data;        // the string from the link that opened this run, or null
sdk.share.bytes;       // the Uint8Array from the link that opened this run, or null
sdk.share.available;   // whether this host can make links at all
  • You never see the URL. The host builds it and decides how to offer it.
  • There is nothing to wait for. offer returns nothing. The host shows the share sheet or copies the link, and tells the player what happened ("Link copied" included). Don't draw your own message about it, and keep the game running: the player can leave the sheet open as long as they like. Pressing Share again while a sheet is open doesn't open a second one.
  • Only share when the player presses something. Hosts may refuse offers nobody asked for.
  • Show your Share button from sdk.share.available, never from sdk.share.data: null there only means this player didn't arrive by a link.
  • Treat sdk.share.data and sdk.share.bytes as untrusted input. They come from outside your game. Check them as strictly as you would any user input.
  • data is a string, which comes back as sdk.share.data, or a Uint8Array, which comes back as sdk.share.bytes. Either way it is at most 512 bytes (SHARE_BYTES) on every host. Bytes pack a board or a replay far tighter than text: see @hopple/bytes. Put anything bigger in your save.
  • meta is any JSON object, at most 1024 bytes of JSON (SHARE_META_BYTES) on every host. No game ever reads it back.

Errors go to onError: too_large, rate_limited (the host won't offer a link right now), bad_params (data isn't a string or a Uint8Array, or meta isn't a JSON object), unsupported (bytes, on a host too old to carry them). On a host that can't make links, offer does nothing.

The website and the dev shell support sharing.

Panel

Hosts have a panel of their own that opens over the game. Some of its pages are about the game: its details, the scoreboard, the achievements list, and the key-binding page. A game can ask the host to open the panel on one of them, for example from its own pause menu.

sdk.panel.pages;            // e.g. ['about', 'scores', 'input']: the pages this host offers for this game
sdk.panel.available;        // false where there are none
sdk.panel.open('scores');   // returns nothing: what happens arrives through sdk.panel.on

The possible pages are about, scores, achievements and input. A game that plays over the network also has room, the player's party, from @hopple/sdk-preview-network.

  • Check sdk.panel.pages before drawing a button for a page. It is known from the start, so you can decide while laying out your menu. If the host has no input page, draw your own key-binding screen.
  • The list depends on the game as well as the host: a game with no score boards won't have scores.
  • open() on a page the host doesn't offer does nothing. Any other refusal (rate_limited: asked too often, or mid-run) goes to onError.
  • There is no way to close the panel. The player closes it.

You can also find out when the panel is over the game, whether you opened it or the player did, and on which of the game's pages:

sdk.panel.isOpen;   // correct from the first frame
sdk.panel.page;     // 'input' | null: null when closed, or open on a page of the host's own
sdk.panel.on((now, previous) => {
  if (!now.isOpen) undim();             // closed
  else if (!previous.isOpen) dim();     // opened
  else redrawFor(now.page);             // moved to another page: don't undim here
});

The open panel also appears in sdk.layout.obstructions as an overlay. Use layout for "where can I draw", and panel for "what is the player looking at". The panel opening is not an order: the game's loop and sound stay allowed under it. If the game is running, the host asks it to pause through pause (below); otherwise what you do about it is your choice.

Input

Actions (named buttons), thumbsticks, a pointer, and on-screen touch controls. Using this is optional; a game can still read the DOM's keyboard, pointer or gamepad events directly. What sdk.input adds is key remapping, controller support, on-screen controls on phones, and correct button labels.

Declare your actions and control layouts in the manifest (see the manifest's reference). The host reads them from the published manifest and sends them at the handshake, with the player's remapped bindings and what device they are using. Key presses are read inside the frame.

Events

sdk.input.onAction('shoot', (down) => { … });           // pressed or released, from any bound key, button or tap
sdk.input.onStick('pad.left.stick', ({ x, y }) => { … });   // -1…1 on each axis
sdk.input.onPointer((pointer) => { … });               // { x, y, down } | null (null: not over the game)

Each returns a function that removes the listener. An action or on-screen layout the manifest doesn't declare is a compile error with init<typeof config.manifest>(), and is reported to onError without it.

An action bound to several things (say a key and a gamepad button) is pressed while any of them is held, and released when the last one is let go. Everything held is released automatically when the game loses focus, its loop is stopped, the bindings change, or you call suspend(), so a key can't get stuck.

The host has a controller's home button, and Start held, to open its panel; a tap of Start is the game's. While the panel is up it has the focus, and a game hears no pad until the focus comes back. A game that reads navigator.getGamepads() itself sees every press, the host's included.

Polling once per frame

sdk.input.poll = true;   // turn on pointer tracking; keys, taps and gamepads are always tracked

sdk.loop.tick(() => {
  const now = sdk.input.read();
  if (now.started('jump')) jump();          // went down since the last read
  if (now.pressed('left')) moveLeft();      // is held now
  const { x, y } = now.stick('pad.left.stick');
});

Call read() once per frame. It reports presses that started and ended since the previous call (so a tap shorter than a frame isn't lost), which means a second call in the same frame sees none of them.

Showing which button to press

prompt.textContent = `Press ${sdk.input.glyph('shoot')} to shoot`;   // written once
prompt.setAttribute('aria-label', `Press ${sdk.input.describe('shoot')} to shoot`);

ctx.font = `20px 'My Font', sans-serif, ${sdk.input.font}`;           // on a canvas
ctx.fillText(`Press ${sdk.input.glyph('shoot')} to shoot`, x, y);
body { font-family: 'My Font', sans-serif, hopple-keys; }

sdk.input.glyph(action) is one character, drawn by a font the SDK builds: every key or button bound to the action for the device in the player's hands — [Z] / [Space] as keycaps, a pad's face buttons as a diamond with the one to press filled, the same on every controller, the on-screen button on a touch screen. When the player rebinds the action or picks up another device the SDK swaps the font, so text in the DOM is right without being touched, and a canvas is right the next time it is painted (a canvas drawn once redraws on sdk.input.on). glyph('pad.start') draws a button that is none of your actions.

Name hopple-keys (sdk.input.font) last in every font stack that prints a glyph, the canvas's included. A stack without it draws a box; one that names it first changes the line height of its text in some browsers. Children inherit a body rule, form controls don't unless they set font: inherit. Under hopple dev a glyph printed anywhere else is reported to onError.

sdk.input.describe(action) is the same in words (Z or Space, bottom face button), for an aria-label and for text drawn without fonts. sdk.input.mapping[action].label still names one binding in text, and sdk.input.scheme is what the player is using: 'keyboard' | 'mouse' | 'touch' | 'gamepad'. sdk.input.on fires when the player rebinds a key (mapping changed) or switches device (scheme changed).

On-screen controls

sdk.input.controls.show('play');   // show the 'play' layout from your manifest
sdk.input.controls.hide();
sdk.input.controls.showing;        // whether controls are actually visible
  • They are hidden by default. Show them when play starts; hide them on menus.
  • Check controls.showing, not whether you called show(). On a desktop, or when the layout doesn't fit, the host says no and they stay hidden.
  • Hiding them only stops drawing them; keys and gamepads keep working.
  • While shown, they appear in sdk.layout.obstructions as reserved rectangles, so code that avoids the host's buttons avoids them too.
  • They are drawn in a shadow root inside your frame, so they don't appear in your DOM and your CSS only reaches them where it is meant to: through hopple-controls::part(…) and the --hopple-pad-* properties (see Styling them).

Styling the on-screen controls

hopple-controls {
  --hopple-pad-tint: #2b1d0ecc;            /* a button's fill */
  --hopple-pad-edge: #f5c542;              /* rims, the knob */
  --hopple-pad-ink: #fff8e1;               /* labels and icons */
  --hopple-pad-radius: 14px;
  --hopple-pad-font: 700 16px/1 'My Font', sans-serif, hopple-keys;
}
hopple-controls::part(face pressed) { translate: 0 2px; }
hopple-controls::part(face action-jump) { background: url(ui/jump.png) center / 70% no-repeat; }
sdk.input.icon('jump', 'M12 3 4 13h5v8h6v-8h5z');   // a filled path in a 24×24 box, or null
  • Parts: face, label and icon on a button; group behind a group of buttons; well, knob, groove and arrow on a stick. Each also carries action-<id> and control-<id>, and the states pressed, held, floating and resting.
  • <hopple-controls> has layout, sticks and look attributes, and a stick has --hopple-stick-x and --hopple-stick-y (−1 to 1) for a game to read.
  • Sizes (--hopple-pad-size, -stick, -track, -gap, -inset) are held between a size a thumb can press and one that leaves the game room. How big one control is next to the others is its scale in the manifest. What a part draws never changes where a touch lands.
  • A player can turn a game's look off, for every game or for one, and gets the plain controls back: test that they still read well over the game.
  • Under hopple dev, a rule that names a part or a property the controls don't have is reported to onError.

Several players on one device

A manifest's input.player declares what each local player does, and players.local how many may play on one device: one set of actions every player has a copy of, bound once for every pad, and the parts of the keyboard players share (keyboard, each with a title and its keys).

sdk.input.joining = true;                       // the screen where players join
sdk.input.onPlayers((players) => drawRoster(players));
for (const player of sdk.input.players) {       // the first player is always there
  const now = player.read();                    // once a frame, as sdk.input.read()
  if (now.started('bomb')) dropBomb(player.slot);
  label[player.slot].textContent = player.glyph('bomb');   // their own key or button
}
sdk.input.joining = false;                      // play starts
  • The first player hears every controller nobody claimed, so a player alone loses nothing between devices. Setting joining gives them the one they last used; a join action pressed on a free pad or part of the keyboard seats a new player with it, up to mostPlayers, and is nobody else's press.
  • slot is a player's place from 0, kept until leave(). controller is what they hold, or null once their pad went away: they keep their place, and the next free pad pressed is theirs again.
  • sdk.input.read() and onAction answer for player actions too, as anybody pressing them, for menus any player may work.
  • In a match, the device tells the match how many play on it, and mostPlayers takes no more than the match has room for.

Text fields

If your game draws its own text input (on a canvas, for example), call sdk.input.suspend() while it has focus and sdk.input.resume() afterwards, so typing doesn't trigger actions. Ordinary <input>, <textarea> and contenteditable elements are handled automatically.

Audio

The host decides whether the game may make sound (sdk.audio.allowed) and how loud. A game is required to obey both. A game that plays sound when it isn't allowed, or while muted, is not accepted on the platform.

The SDK can obey it for you. Route every sound through it:

const sound = new AudioContext();
source.connect(sdk.audio.output(sound, 'music'));   // a GainNode kept at the channel's volume
sdk.audio.media(tuneElement, 'music');              // an <audio> or <video> element, likewise

A sound routed this way follows the volume and mute at once, is silenced the moment sound isn't allowed (before the host is told the game has gone quiet), resumes when it is, and starts on the player's first press in the frame.

A game whose engine mixes for itself reads the volume instead:

engine.volume = sdk.audio.volume('music');   // 0 to 1: multiply this channel's sound by it

sdk.audio.on(() => {
  engine.volume = sdk.audio.volume('music');   // apply it at once: mute means silent now
  if (sdk.audio.allowed) engine.resume();
  else engine.pause();                         // optional: volume() is already 0
});
  • Use volume(channel). It already combines allowed, mute, the master volume and that channel's volume. The separate parts (sdk.audio.muted, sdk.audio.master, sdk.audio.channel(id)) are only for drawing your own volume screen; mixing from them is an easy way to break mute later.
  • Mute is not the same as volume 0. They are separate, so unmuting brings back the volume the player had.
  • Declare your channels in the manifest (see the manifest's reference) to get a labelled slider for each one in the host's sound controls. A channel name is checked against the manifest with init<typeof config.manifest>(); without it, volume() for an undeclared channel returns the master volume.
  • The opening volume is in the welcome, so you know whether you are muted before playing your first note.
  • When the volume changes, the SDK answers the host with the volume it now holds, as soon as every listener has been called. Nothing a listener returns is waited for: a mute is silent now.
  • On a host with no volume controls, everything is at full volume.

Pause

The host can ask the game to pause or resume, and the game says whether it can.

sdk.pause.state;        // 'running' | 'paused' | 'unpausable'
sdk.pause.set('paused');   // tell the host where you now are, whoever caused it

sdk.pause.on(async (paused) => {   // the host asked; return a promise to make it wait
  if (nothingToPause()) return;    // stay 'unpausable'
  holdEverything(paused);
  sdk.pause.set(paused ? 'paused' : 'running');
});
  • unpausable is a normal state, not an error: a title screen, a cut scene, the gap between rounds. While you are unpausable, the host hides its pause button and doesn't ask.

  • Every game starts unpausable. Call set('running') once there is something to pause, and keep it up to date.

  • Also call set() when the game pauses by itself (the player pressed your own pause key, or a round ended), so the host's button shows the right thing.

  • The host asks a running game to pause when its panel opens over it, and when it takes the game away from the player, and doesn't ask it to resume for either: resuming is the player's.

  • Keep pause separate from the loop. sdk.loop.allowed is what the host lets the game run; pause is what the player asked for. If you merge them, the game will resume a player's pause as soon as its loop is allowed again:

    sdk.loop.tick((dt) => {   // stopped by the host's order
      if (sdk.pause.state !== 'paused') update(dt);   // held still by the player's pause
      draw();
    });

Match

Play over the network, in matches: sdk.match. It is a preview, not stable yet, so it is a package of its own, @hopple/sdk-preview-network, which the game installs and declares in its manifest as previews.network, at its revision. A game that does not declare it has no sdk.match.

Checklist

  • [ ] Let a rejected init() through rather than catching it.
  • [ ] Call sdk.lifecycle.ready() when you can draw.
  • [ ] Run the loop only while sdk.loop.allowed and make sound only while sdk.audio.allowed (sdk.loop.tick and sdk.audio.output do); free memory when sdk.resources.allowed is false.
  • [ ] Keep touch targets out of reserved areas (sdk.layout).
  • [ ] Leave out what isn't available, and treat unsupported as "not available", never as a crash.
  • [ ] Save as you play, and await the write.
  • [ ] Never treat known: false (unknown) as nothing there.
  • [ ] Declare every analytics event, score board, achievement, action and audio channel in the manifest, and pass its type to init.
  • [ ] Submit every run's score. Only show a rank when you get one.
  • [ ] Share and open the panel only from a player's press.
  • [ ] Validate sdk.share.data like any untrusted input.
  • [ ] Print button prompts with sdk.input.glyph (and sdk.input.describe for words), and read input once per frame.
  • [ ] Route every sound through sdk.audio.output or media, or multiply it by sdk.audio.volume(channel), and obey mute.
  • [ ] Keep sdk.pause up to date, separately from the loop.
  • [ ] For network play, install @hopple/sdk-preview-network and declare previews.network, leave the lobby and invites to the host, and start matches from a player's press.
  • [ ] Load the runtime as the page's first script, <script src="_rt/hopple.js"></script>, and import @hopple/sdk rather than bundling a copy.
  • [ ] Refer to every file relatively, and publish the output directory.

License

MIT; see LICENSE. A game carries the stub, which carries its one-line notice, /*! @hopple/sdk <version> | Copyright (c) 2026 The Hopple authors | MIT License */. Bundlers keep comments that start with /*! unless they minify them away: with Vite, set build.rolldownOptions.output.comments.legal to true, as a hopple create project does. The runtime, which is the SDK itself, is MIT too, and is served beside the game rather than carried in it. The license covers the code, not the Hopple name or logo.