@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
- Getting started
- Lifecycle: when to run
- Layout: where to draw
- Info: facts about the player and device
- Analytics
- Save
- Score
- Achievements
- Share
- Panel
- Input
- Audio
- Pause
- Match
- Checklist
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 publishuploads the directory the page is in, with the page as itsindex.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:andblob: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 namingHopple.save, andhopple checkrefuses a game that uses them. Usesdk.saveinstead. - Every address relative, no
<base>, and UTF-8 pages.hopple checkrefuses the rest. - No
alert,confirmorprompt. 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.outputstarts 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/sdkand import it. The package is the SDK's types, with every doc comment, innode_modules/@hopple/sdk/index.d.ts, and a stub of a few lines that forwards each import toHopple: your bundle carries the stub, never the SDK. Set your bundler to write relative addresses (Vite:base: './'). - Without one: use the global
Hoppledirectly (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 aBridgeErrorwhose code isunsupported. 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 toonError. 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 insetsEach 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.tieris 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.idis different for every game, so two games can't recognise the same player.nullmeans the host has no notion of a player.player.localeis the language the player chose in the host, which may differ fromnavigator.language.app.surfaceis'dev'only insidehopple dev. Use it for debug overlays, never for game logic.game.versioncomes 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 manifesttrack 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 acceptssdk.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, callread()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. versionis 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 theifyourself.- Strings are stored as they are, and a
Uint8Arrayas its bytes; anything else goes throughJSON.stringify. Each comes back as it went in: a string, aUint8Array, or the parsed value. So aMap, aDateor a typed array inside an object won't come back the way it went in. Insidehopple dev, the SDK warns (viaonError) when you save something JSON would change. - Bytes pack a big save small. Write a
Uint8Arraywith@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 aUint8Array. A larger save is rejected withtoo_largebefore 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 thismerge(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 nomerge.- Asked while the game loads, the merge is what
read()resolves with. Asked while it plays, it goes toonChangelisteners, 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, beforeonChangehears it. - Inside
hopple dev, the SDK warns (viaonError) when the save changed while nothing listened withonChange.
| 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. boardcan be left out if the game has exactly one board.submitresolves once the score is stored on the host's side, which may be before it reaches a server. That is whyrankandofare optional: only show a rank when you were given one.standinghas the same three answers as the save status: a board's standing,null(never scored on this board), orknown: false(unknown right now). Don't show "no scores yet" forknown: 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
unlockon a measured achievement completes it (for a shortcut or a cheat code); its progress is set to the target. standinghas three answers, like the score's: don't showknown: falseas "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.
offerreturns 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 fromsdk.share.data:nullthere only means this player didn't arrive by a link. - Treat
sdk.share.dataandsdk.share.bytesas untrusted input. They come from outside your game. Check them as strictly as you would any user input. datais a string, which comes back assdk.share.data, or aUint8Array, which comes back assdk.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.metais 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.onThe 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.pagesbefore 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 noinputpage, 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 toonError.- 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 calledshow(). 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.obstructionsas 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,labelandiconon a button;groupbehind a group of buttons;well,knob,grooveandarrowon a stick. Each also carriesaction-<id>andcontrol-<id>, and the statespressed,held,floatingandresting. <hopple-controls>haslayout,sticksandlookattributes, and a stick has--hopple-stick-xand--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 itsscalein 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 toonError.
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
joininggives 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 tomostPlayers, and is nobody else's press. slotis a player's place from 0, kept untilleave().controlleris what they hold, ornullonce their pad went away: they keep their place, and the next free pad pressed is theirs again.sdk.input.read()andonActionanswer 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
mostPlayerstakes 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, likewiseA 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 combinesallowed, 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');
});unpausableis a normal state, not an error: a title screen, a cut scene, the gap between rounds. While you areunpausable, the host hides its pause button and doesn't ask.Every game starts
unpausable. Callset('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.allowedis 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.allowedand make sound only whilesdk.audio.allowed(sdk.loop.tickandsdk.audio.outputdo); free memory whensdk.resources.allowedisfalse. - [ ] Keep touch targets out of reserved areas (
sdk.layout). - [ ] Leave out what isn't
available, and treatunsupportedas "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.datalike any untrusted input. - [ ] Print button prompts with
sdk.input.glyph(andsdk.input.describefor words), and read input once per frame. - [ ] Route every sound through
sdk.audio.outputormedia, or multiply it bysdk.audio.volume(channel), and obey mute. - [ ] Keep
sdk.pauseup to date, separately from the loop. - [ ] For network play, install
@hopple/sdk-preview-networkand declarepreviews.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/sdkrather 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.
