@air-jam/sdk
v0.9.2
Published
The core SDK for building Air Jam games and controllers
Downloads
487
Maintainers
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:
- Durable v1 surface.
@air-jam/sdk,@air-jam/sdk/ui, and@air-jam/sdk/styles.cssare 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. - Non-breaking v1.x. Within the
1.xline 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. - 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/prefabsare intentionally unstable future-facing seams. They may change within1.x— each carries a documented purpose in its leaf, and changes are noted in release notes. The agent-facingruntime-control,runtime-inspection,runtime-observability, andcontracts/v2seams 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. - 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.
- Metadata and capability contracts. The
defineAirJamGameMetadataanddefineAirJamGameCapabilitieshelpers 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)
Input lane(high frequency, transient):useControllerTick+useInputWriteron controller,host.getInput/useGetInputon host.State lane(replicated, host-authoritative):createAirJamStore+useActionswith(ctx, payload)action handlers.Signal lane(out-of-band UX/system):sendSignal/sendSystemCommandfor haptics, toasts, room-level commands, and remote audio cues.
Do not mix lanes:
- Do not send per-frame stick/button input through store actions.
- Do not mutate authoritative game state via signals.
- Do not dispatch state actions via
state.actions.*; useuseStore.useActions().
Installation
pnpm add @air-jam/sdk zodMinimal 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:
- Static
appIdmode - 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_idIf you want stricter ownership guarantees while keeping the game static, add:
VITE_AIR_JAM_HOST_GRANT_ENDPOINT=/api/airjam/host-grantand 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:
- mount
AirJamHostRuntime/AirJamControllerRuntimeonce per runtime surface - use
useAirJamHost()/useAirJamController()only as read hooks below that boundary - wrap controller UI in
SurfaceViewportand set itsorientationthere
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:
- phone controllers remain the canonical product experience
- preview controllers are not a second topology or fake simulator path
- preview controllers use the real controller route and join the same room as normal controllers
- production should stay explicit opt-in
The preview workspace also exposes local recovery controls:
- source-aware
Kickcontrols for room controllers 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:
HostPreviewControllerWorkspacefor normal host-surface mountingPreviewControllerWorkspaceandPreviewControllerWindowfor lower-level compositionusePreviewControllerManagerfor host-local preview session statebuildPreviewControllerUrland 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:
@air-jam/sdk/runtime-control@air-jam/sdk/runtime-inspection@air-jam/sdk/runtime-observability@air-jam/sdk/contracts/v2
Current policy:
- keep these seams private until a real first-party consumer lands
- treat them as future agent-facing homes for bots, tests, previews, and agent tooling
- 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:
- this leaf defines prefab contracts, not a prefab runtime or editor
- keep prefab definitions separate from scene population, pooling, or runtime spawn systems
- keep larger gameplay behavior in domain, engine, or adapter modules instead of burying it inside prefab metadata
- treat this as the future-facing namespace for Studio and agent-oriented prefab tooling
Current scope:
definePrefab(...)for a stable prefab definition shapecreatePrefabCatalog(...)for a game-owned prefab registry export- 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:
- mount
PlatformSettingsRuntime persistence="local"once in the platform shell - let
airjam.Host,airjam.Controller,AirJamHostRuntime, andAirJamControllerRuntimesupply the in-game settings boundary automatically - embedded games inherit platform settings read-only
- keep platform settings limited to shared cross-game concerns like audio, accessibility, and feedback
- 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:
ctx.actorIdis always the identity of the dispatcher- if host code calls
useActions(), thenctx.actorId === "host" - if host code intentionally wants to run the same semantic action as controller
X, useuseGameStore.asPlayer("X")instead of smugglingcontrollerIdthrough an unrelated payload
Every action dispatch now resolves to an acknowledgement:
{ ok: true, status: "accepted", source, result? }{ ok: false, status: "rejected", source, reason, message, details? }
Networked action contract:
() => Promise<ack>for no-payload actions(payloadObject) => Promise<ack>for payload actions- payload roots must be plain objects, not primitives, arrays, DOM events, or
T | undefinedunions - if an action has no payload, omit it entirely instead of using
undefinedas 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)
- Define one
airjamapp config withcreateAirJamApp. - On controllers, publish input with
useControllerTick+useInputWriter. - On hosts, read input with
getInput/useGetInput. - For host gameplay loops, prefer
useHostTick(...)over hand-rolledrequestAnimationFrameorsetIntervalloops. - Keep replicated gameplay state in
createAirJamStore. - 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_URLVITE_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
