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

@air-jam/sdk

v0.9.2

Published

The core SDK for building Air Jam games and controllers

Downloads

487

Readme

@air-jam/sdk

Core SDK for building Air Jam hosts and mobile controllers.

Stability And Compatibility Policy

Air Jam SDK follows semver. What that means concretely for creators building against v1:

  1. Durable v1 surface. @air-jam/sdk, @air-jam/sdk/ui, and @air-jam/sdk/styles.css are the stable authoring and UI lanes. Breaking changes on these require a major version bump, and v1 will be kept working for games for at least 12 months after v1.0.0 ships.
  2. Non-breaking v1.x. Within the 1.x line we commit to: no removals, no incompatible type changes, and no silent behavior changes on the stable lanes above. New functionality lands as additive APIs.
  3. Experimental leaves. @air-jam/sdk/preview, @air-jam/sdk/arcade*, @air-jam/sdk/protocol, @air-jam/sdk/capabilities, @air-jam/sdk/metadata, and @air-jam/sdk/prefabs are intentionally unstable future-facing seams. They may change within 1.x — each carries a documented purpose in its leaf, and changes are noted in release notes. The agent-facing runtime-control, runtime-inspection, runtime-observability, and contracts/v2 seams exist in-source but are not exported publicly until a first-party consumer lands; they will be re-exported as explicit experimental leaves when that happens.
  4. v2 migration. When v2 ships, we commit to publishing a codemod (or migration notes if the surface is too narrow to automate) alongside the release. v1 games will not be silently broken — the v1 SDK will remain installable.
  5. Metadata and capability contracts. The defineAirJamGameMetadata and defineAirJamGameCapabilities helpers produce versioned, frozen objects. Contract versions are bumped explicitly so the platform can accept mixed versions during a transition window.

Games built against v1 should declare supportedSdkRange: "^1.0.0" in their metadata unless they intentionally pin tighter.

Three Lanes (Canonical)

  1. Input lane (high frequency, transient): useControllerTick + useInputWriter on controller, host.getInput / useGetInput on host.
  2. State lane (replicated, host-authoritative): createAirJamStore + useActions with (ctx, payload) action handlers.
  3. Signal lane (out-of-band UX/system): sendSignal / sendSystemCommand for haptics, toasts, room-level commands, and remote audio cues.

Do not mix lanes:

  1. Do not send per-frame stick/button input through store actions.
  2. Do not mutate authoritative game state via signals.
  3. Do not dispatch state actions via state.actions.*; use useStore.useActions().

Installation

pnpm add @air-jam/sdk zod

Minimal Setup

Use defineAirJamGameMetadata for catalog-facing identity and createAirJamApp for runtime/session wiring. Platform submissions can still edit metadata in the dashboard; the code export gives tooling a typed default to prefill, validate, and compare against release artifacts.

import { createAirJamApp, env } from "@air-jam/sdk";
import { defineAirJamGameMetadata } from "@air-jam/sdk/metadata";
import { z } from "zod";

const inputSchema = z.object({
  vector: z.object({ x: z.number(), y: z.number() }),
  action: z.boolean(),
});

export const gameMetadata = defineAirJamGameMetadata({
  slug: "my-game",
  name: "My Game",
  tagline: "A short catalog pitch for players.",
  category: "party",
  minPlayers: 1,
  maxPlayers: 4,
  inputModalities: ["buttons", "touch"],
  supportedSdkRange: "^1.0.0",
  maintainer: { name: "Your Name" },
});

export const airjam = createAirJamApp({
  runtime: env.vite(import.meta.env),
  controllerPath: "/controller",
  input: { schema: inputSchema },
});

Production Auth Modes

There are two canonical production modes:

  1. Static appId mode
  2. Optional signed host-grant mode

Static appId mode is the default. Set:

VITE_AIR_JAM_SERVER_URL=https://api.airjam.io
VITE_AIR_JAM_APP_ID=aj_app_your_app_id

If you want stricter ownership guarantees while keeping the game static, add:

VITE_AIR_JAM_HOST_GRANT_ENDPOINT=/api/airjam/host-grant

and have that endpoint return:

{ "hostGrant": "..." }

The SDK fetches the host grant automatically before host:bootstrap. Game code stays unchanged.

Host Usage

Mount runtime ownership explicitly at the host boundary, then read it from child code with useAirJamHost().

If a component only needs host session state, call useAirJamHost(selector) to avoid rerendering on unrelated runtime fields. Selectors receive state fields only; call useAirJamHost() when a component also needs runtime controls such as joinUrl, sendSignal, or getInput.

import { AirJamHostRuntime, env, useAirJamHost } from "@air-jam/sdk";

const HostShell = () => (
  <AirJamHostRuntime
    topology={env.vite(import.meta.env).topology}
    appId={import.meta.env.VITE_AIR_JAM_APP_ID}
    input={{ schema: inputSchema }}
    onPlayerJoin={(player) => console.log("joined", player.id)}
    onPlayerLeave={(controllerId) => console.log("left", controllerId)}
  >
    <HostView />
  </AirJamHostRuntime>
);

export const HostView = () => {
  const host = useAirJamHost();
  return (
    <section>
      <h1>Room: {host.roomId}</h1>
      <p>Status: {host.connectionStatus}</p>
      <p>Join URL: {host.joinUrl}</p>
    </section>
  );
};

Use host.players for gameplay-facing player iteration. Use host.controllers when you need the richer controller-session roster with source (phone | preview | virtual), connected state, and resume-lease metadata. Use host.resetRoom() when you need a fresh empty room without restarting the local backend.

Use host.getInput(controllerId) in your game loop.

Default input behavior:

  • booleans: pulse (tap-safe consume-on-read)
  • vectors: latest (continuous latest value)

Optional overrides are available via input.behavior (pulse | hold | latest).

Migration From input.latch

If you previously used:

input: {
  schema,
  latch: {
    booleanFields: ["action"],
    vectorFields: ["vector"],
  },
}

Use:

input: {
  schema,
  behavior: {
    pulse: ["action", "vector"],
  },
}

Notes:

  • most games can now remove input behavior config entirely (input: { schema })
  • booleans default to tap-safe pulse
  • vectors default to latest (continuous)

Controller Usage

Mount runtime ownership explicitly at the controller boundary, then read it from child code with useAirJamController().

If a component only needs controller session state, call useAirJamController(selector) to avoid rerendering on unrelated runtime updates. Selectors receive state fields only; call useAirJamController() when a component also needs controls such as sendSystemCommand.

import {
  AirJamControllerRuntime,
  env,
  useAirJamController,
  useControllerTick,
  useInputWriter,
} from "@air-jam/sdk";
import { SurfaceViewport } from "@air-jam/sdk/ui";

const ControllerShell = () => (
  <AirJamControllerRuntime
    topology={env.vite(import.meta.env).topology}
    appId={import.meta.env.VITE_AIR_JAM_APP_ID}
    nickname="Player 1"
  >
    <ControllerView />
  </AirJamControllerRuntime>
);

export const ControllerView = () => {
  const controller = useAirJamController();
  const writeInput = useInputWriter();

  useControllerTick(
    () => {
      writeInput({
        vector: { x: 0, y: 0 },
        action: false,
      });
    },
    {
      enabled:
        controller.connectionStatus === "connected" &&
        controller.runtimeState === "playing",
      intervalMs: 16,
    },
  );

  return (
    <SurfaceViewport orientation="portrait">
      <section>
        <button
          onPointerDown={() =>
            writeInput({
              vector: { x: 0, y: 0 },
              action: true, // one-shot pulses still valid
            })
          }
        >
          Action
        </button>
      </section>
    </SurfaceViewport>
  );
};

Controllers usually join via URL query param: /controller?room=ABCD.

Standalone controllers also keep one stable local device identity and the last room-scoped controller binding. If the same phone refreshes or briefly disconnects, the SDK automatically attempts to resume that controller binding instead of creating a duplicate player.

The important rule is:

  1. mount AirJamHostRuntime / AirJamControllerRuntime once per runtime surface
  2. use useAirJamHost() / useAirJamController() only as read hooks below that boundary
  3. wrap controller UI in SurfaceViewport and set its orientation there

When the controller runs inside Arcade, SurfaceViewport automatically publishes its orientation to the parent Arcade chrome. The same component still handles standalone controller layout, so games do not need a separate host-side orientation bridge. Multiplayer game state should live in the networked stores and replicate automatically.

Preview Controllers (Experimental)

The preview-controller feature lives under the explicit experimental leaf:

import { HostPreviewControllerWorkspace } from "@air-jam/sdk/preview";

Use it when you want a fast local desktop tryout path without replacing the normal phone-controller product model.

Important rules:

  1. phone controllers remain the canonical product experience
  2. preview controllers are not a second topology or fake simulator path
  3. preview controllers use the real controller route and join the same room as normal controllers
  4. production should stay explicit opt-in

The preview workspace also exposes local recovery controls:

  1. source-aware Kick controls for room controllers
  2. Reset room, which creates a fresh empty room and reloads the local host page

Recommended host usage:

import { HostPreviewControllerWorkspace } from "@air-jam/sdk/preview";

export const HostView = () => {
  const previewControllersEnabled = import.meta.env.DEV;

  return (
    <>
      <GameCanvas />
      <HostPreviewControllerWorkspace enabled={previewControllersEnabled} />
    </>
  );
};

The shared preview leaf currently provides:

  1. HostPreviewControllerWorkspace for normal host-surface mounting
  2. PreviewControllerWorkspace and PreviewControllerWindow for lower-level composition
  3. usePreviewControllerManager for host-local preview session state
  4. buildPreviewControllerUrl and related launch helpers for explicit launch control

The floating window chrome also supports per-window portrait/landscape rotation, and active preview-window opacity is driven by the shared platform settings runtime so hosts can persist their preferred transparency level.

Keep preview usage inside explicit host-side UI and do not treat it as a stable root-SDK contract yet.

Runtime Contract Seams (In-Source, Not Public Exports)

Air Jam also carries agent-facing runtime seams for control, inspection, and observability. Those modules still exist in-source, but they are not public package exports in v1, so consumers should not import:

  1. @air-jam/sdk/runtime-control
  2. @air-jam/sdk/runtime-inspection
  3. @air-jam/sdk/runtime-observability
  4. @air-jam/sdk/contracts/v2

Current policy:

  1. keep these seams private until a real first-party consumer lands
  2. treat them as future agent-facing homes for bots, tests, previews, and agent tooling
  3. re-export them later as explicit experimental leaves instead of implying they are stable root-SDK contracts

Prefab Contract Leaf (Experimental)

Prefab definitions should live under a stable, scanable contract instead of drifting between scene glue, render layers, and runtime population code.

The first prefab contract helpers now live under the explicit experimental leaf:

import {
  createPrefabCatalog,
  definePrefab,
  type PrefabDefinition,
} from "@air-jam/sdk/prefabs";

Use this leaf when you need a canonical prefab definition that future tooling can scan, preview, configure, and catalog.

Important rules:

  1. this leaf defines prefab contracts, not a prefab runtime or editor
  2. keep prefab definitions separate from scene population, pooling, or runtime spawn systems
  3. keep larger gameplay behavior in domain, engine, or adapter modules instead of burying it inside prefab metadata
  4. treat this as the future-facing namespace for Studio and agent-oriented prefab tooling

Current scope:

  1. definePrefab(...) for a stable prefab definition shape
  2. createPrefabCatalog(...) for a game-owned prefab registry export
  3. shared preview and placement descriptor types for future Studio/catalog work

Controller Feedback Helpers

Use explicit audio ownership at each host/controller surface boundary, then consume that runtime-owned manager below it. AudioRuntime is role-aware: controller surfaces automatically receive host-triggered remote sounds.

import { AudioRuntime, useAudio, useControllerToasts } from "@air-jam/sdk";

const manifest = {
  hit: { src: ["/sounds/hit.wav"] },
};

const ControllerHud = () => {
  const audio = useAudio();
  const { latestToast } = useControllerToasts();

  return latestToast ? <p>{latestToast.message}</p> : null;
};

const ControllerShell = () => (
  <AudioRuntime manifest={manifest}>
    <ControllerHud />
  </AudioRuntime>
);

Host-side sendSignal("TOAST", ...) now pairs directly with useControllerToasts(). Mount AudioRuntime once per runtime surface, then call useAudio() only below that boundary.

Shared Platform Settings

Shared user settings are platform-owned and inherited by embedded games.

Mount PlatformSettingsRuntime once in the platform shell when you want a persisted owner runtime. AirJamHostRuntime / AirJamControllerRuntime already provide a settings boundary for games, so repo games should not wrap each host/controller surface in another redundant PlatformSettingsRuntime.

import {
  PlatformSettingsRuntime,
  useInheritedPlatformSettings,
  usePlatformAudioSettings,
} from "@air-jam/sdk";

const ArcadeShell = () => (
  <PlatformSettingsRuntime persistence="local">
    <SettingsPanel />
    <EmbeddedGame />
  </PlatformSettingsRuntime>
);

const SettingsPanel = () => {
  const { masterVolume, setMasterVolume } = usePlatformAudioSettings();
  return (
    <button onClick={() => setMasterVolume(masterVolume === 0 ? 1 : 0)}>
      Toggle Master
    </button>
  );
};

const EmbeddedGame = () => {
  const settings = useInheritedPlatformSettings();
  return <pre>{JSON.stringify(settings.audio, null, 2)}</pre>;
};

Rules:

  1. mount PlatformSettingsRuntime persistence="local" once in the platform shell
  2. let airjam.Host, airjam.Controller, AirJamHostRuntime, and AirJamControllerRuntime supply the in-game settings boundary automatically
  3. embedded games inherit platform settings read-only
  4. keep platform settings limited to shared cross-game concerns like audio, accessibility, and feedback
  5. do not recreate feature-specific global settings stores alongside this runtime

Optional UI Primitives

@air-jam/sdk/ui exports optional presentational primitives (Button, Slider, PlayerAvatar, VolumeControls). These components are lifecycle-free: they do not create sockets or own host/controller session state. VolumeControls reads and writes the shared audio slice through the platform settings runtime.

Networked State (Host Source of Truth)

Use createAirJamStore for shared game state synced from host to controllers.

import { createAirJamStore } from "@air-jam/sdk";

interface RuntimeState {
  phase: "lobby" | "playing";
  actions: {
    setPhase: (
      ctx: {
        actorId: string;
        role: "controller" | "host";
        connectedPlayerIds: string[];
      },
      payload: { phase: "lobby" | "playing" },
    ) => void;
  };
}

export const useGameStore = createAirJamStore<RuntimeState>((set) => ({
  phase: "lobby",
  actions: {
    setPhase: (_ctx, { phase }) => set({ phase }),
  },
}));

const actions = useGameStore.useActions();
actions.setPhase({ phase: "playing" });

Use useGameStore.useActions() for action dispatch. On controllers, action calls are proxied to the host automatically and actor identity is attached by the server.

Important identity rule:

  1. ctx.actorId is always the identity of the dispatcher
  2. if host code calls useActions(), then ctx.actorId === "host"
  3. if host code intentionally wants to run the same semantic action as controller X, use useGameStore.asPlayer("X") instead of smuggling controllerId through an unrelated payload

Every action dispatch now resolves to an acknowledgement:

  1. { ok: true, status: "accepted", source, result? }
  2. { ok: false, status: "rejected", source, reason, message, details? }

Networked action contract:

  1. () => Promise<ack> for no-payload actions
  2. (payloadObject) => Promise<ack> for payload actions
  3. payload roots must be plain objects, not primitives, arrays, DOM events, or T | undefined unions
  4. if an action has no payload, omit it entirely instead of using undefined as part of an object-union payload type

Action context includes connectedPlayerIds, so host actions can prune stale assignments without custom presence-sync actions.

For host-only local side effects such as sounds, local sim refs, or one-shot animations, use the store's host action listener seam instead of queueing ephemeral commands through replicated state:

useGameStore.useHostActionListener((event) => {
  if (event.actionName !== "fireProjectile") {
    return;
  }

  projectileRuntimeRef.current.spawn(event.payload);
});

For explicit host-side player impersonation, use the host-only player-action lane:

const playerActions = useGameStore.asPlayer("ctrl_2");

await playerActions.joinTeam({ team: "red" });

When a store action should return an explicit semantic result or rejection, use acceptAirJamAction(...) and rejectAirJamAction(...):

import {
  acceptAirJamAction,
  createAirJamStore,
  rejectAirJamAction,
} from "@air-jam/sdk";

export const useGameStore = createAirJamStore((set) => ({
  aliveByPlayerId: {} as Record<string, boolean>,
  actions: {
    fire: ({ actorId }) =>
      set((state) => {
        if (!actorId || state.aliveByPlayerId[actorId] === false) {
          return rejectAirJamAction("player_dead", "Dead players cannot fire.");
        }

        return acceptAirJamAction({ cooldownMs: 4500 });
      }),
  },
}));

If an action returns void, Air Jam treats it as accepted automatically.

Runtime Pause Controls

Runtime pause/play is independent from your game-owned match phase. Keep lobby, playing, and ended state in your game store, and use explicit runtime commands only for pause UI.

const host = useAirJamHost();

<button
  onClick={
    host.runtimeState === "playing" ? host.pauseRuntime : host.resumeRuntime
  }
>
  {host.runtimeState === "playing" ? "Pause" : "Resume"}
</button>;

One Correct Way (Default Path)

  1. Define one airjam app config with createAirJamApp.
  2. On controllers, publish input with useControllerTick + useInputWriter.
  3. On hosts, read input with getInput / useGetInput.
  4. For host gameplay loops, prefer useHostTick(...) over hand-rolled requestAnimationFrame or setInterval loops.
  5. Keep replicated gameplay state in createAirJamStore.
  6. Dispatch all store actions through useActions() zero-arg or payload-object calls and check the returned acknowledgement when outcome matters.

Canonical airjam.config.ts

Keep one runtime/session declaration and use role wrappers directly in routes.

import { createAirJamApp, env } from "@air-jam/sdk";
import { gameInputSchema } from "./types";

export const airjam = createAirJamApp({
  runtime: env.vite(import.meta.env),
  controllerPath: "/controller",
  // Optional agent-facing contracts belong here too.
  // agent: agentContract,
  input: {
    schema: gameInputSchema,
  },
});
import { Route, Routes } from "react-router-dom";
import { airjam } from "./airjam.config";

export const App = () => (
  <Routes>
    <Route
      path="/"
      element={
        <airjam.Host>
          <HostView />
        </airjam.Host>
      }
    />
    <Route
      path={airjam.paths.controller}
      element={
        <airjam.Controller>
          <ControllerView />
        </airjam.Controller>
      }
    />
  </Routes>
);

This keeps runtime config, host input schema, route path ownership, and optional agent-facing contracts in one place.

Optional future-facing game capability metadata should also live here, but the schema is intentionally experimental and lives in @air-jam/sdk/capabilities.

Environment Variables

  • VITE_AIR_JAM_SERVER_URL / NEXT_PUBLIC_AIR_JAM_SERVER_URL
  • VITE_AIR_JAM_APP_ID / NEXT_PUBLIC_AIR_JAM_APP_ID

Full Docs

  • Platform docs: https://airjam.io/docs
  • Monorepo docs index: docs/docs-index.md