Networked State
createAirJamStore is the canonical shared-state lane for Air Jam:
- Host is the only source of truth.
- Controllers dispatch actions through RPC.
- Host applies actions, then state sync is broadcast back.
Use this lane for replicated game state, not per-frame input.
Canonical Action Model
Actions always use this shape on the host:
- First argument:
ctx(identity and role) - Second argument:
payload(eitherundefinedor one typed plain-object payload)
Controller-side dispatch always uses:
const actions = useGameStore.useActions()actions.someAction()oractions.someAction({ ...payload })
No trailing controllerId arguments and no multi-argument action payloads.
No primitive, array, or event-like root payloads.
Create a Store
This example uses a starter-friendly stores/ folder shape.
The important contract is the action model and ownership boundary, not the exact filename.
import { createAirJamStore } from "@air-jam/sdk";
type Team = "team1" | "team2";
interface GameState {
phase: "lobby" | "playing";
teamAssignments: Record<string, Team>;
scores: { team1: number; team2: number };
actions: {
joinTeam: (
ctx: { actorId: string; role: "controller" | "host" },
payload: { team: Team },
) => void;
scorePoint: (
_ctx: { actorId: string; role: "controller" | "host" },
payload: { team: Team },
) => void;
setPhase: (
_ctx: { actorId: string; role: "controller" | "host" },
payload: { phase: "lobby" | "playing" },
) => void;
};
}
export const useGameStore = createAirJamStore<GameState>((set) => ({
phase: "lobby",
teamAssignments: {},
scores: { team1: 0, team2: 0 },
actions: {
joinTeam: ({ actorId }, { team }) =>
set((state) => ({
teamAssignments: {
...state.teamAssignments,
[actorId]: team,
},
})),
scorePoint: (_ctx, { team }) =>
set((state) => ({
scores: {
...state.scores,
[team]: state.scores[team] + 1,
},
})),
setPhase: (_ctx, { phase }) => set({ phase }),
},
}));Use on Controller
This controller example matches the starter template surface layout.
It assumes your app has already mounted airjam.Controller or AirJamControllerRuntime.
import { useAirJamController } from "@air-jam/sdk";
import { useGameStore } from "../game/stores/game-store";
export const ControllerView = () => {
const controller = useAirJamController();
const actions = useGameStore.useActions();
const teamAssignments = useGameStore((state) => state.teamAssignments);
const myTeam = controller.controllerId
? teamAssignments[controller.controllerId]
: null;
return (
<div>
<p>My team: {myTeam ?? "unassigned"}</p>
<button onClick={() => actions.joinTeam({ team: "team1" })}>
Join Team 1
</button>
</div>
);
};Use on Host
This host example also matches the starter template surface layout.
It assumes your app has already mounted airjam.Host or AirJamHostRuntime.
import { useGameStore } from "../game/stores/game-store";
export const HostView = () => {
const phase = useGameStore((state) => state.phase);
const scores = useGameStore((state) => state.scores);
const actions = useGameStore.useActions();
return (
<div>
<p>Phase: {phase}</p>
<p>
{scores.team1} - {scores.team2}
</p>
<button onClick={() => actions.setPhase({ phase: "playing" })}>
Start
</button>
<button onClick={() => actions.scorePoint({ team: "team1" })}>
Team 1 +1
</button>
</div>
);
};Action Flow
- Controller calls
actions.someAction()oractions.someAction({ ...payload }). - SDK emits
controller:action_rpc. - Server validates and injects actor identity from socket ownership.
- Host receives
airjam:action_rpcwith{ actor, payload }. - Host executes action handler
(ctx, payload)and updates state. - Host emits
host:state_sync; controllers receiveairjam:state_sync.
Rules
- Keep all networked mutations inside
actions. - Always dispatch via
useActions(); do not callstate.actions.*directly. - Use one payload object per action for stable evolution.
- Root payloads must be omitted or plain objects.
T | undefinedpayload unions are not valid roots. If an action has no payload, omit it entirely.- Nested values must stay RPC-serializable.
- Use
ctx.actorIdfor identity-aware actions (team join, ownership, etc.). ctx.actorIdis always the dispatcher identity. If host code dispatches throughuseActions(), thenctx.actorIdis the host.- If the host intentionally needs to run the same semantic player action as controller
X, useuseStore.asPlayer("X")instead of inventing a target payload for an actor-owned action. - Keep per-frame gameplay input in
useInputWriter/host.getInput, not in this store.
Accept And Reject Examples
If a store action returns void, Air Jam treats it as accepted.
Return rejectAirJamAction(...) when the semantic action should fail with a clear machine-readable reason, and return acceptAirJamAction(result) when the semantic action should succeed with explicit result data:
import {
acceptAirJamAction,
createAirJamStore,
rejectAirJamAction,
} from "@air-jam/sdk";
export const useGameStore = createAirJamStore((set) => ({
aliveByPlayerId: {} as Record<string, boolean>,
cooldownMsByPlayerId: {} as Record<string, number>,
actions: {
fire: ({ actorId }) =>
set((state) => {
if (!actorId || state.aliveByPlayerId[actorId] === false) {
return rejectAirJamAction("player_dead", "Dead players cannot fire.");
}
const cooldownMs = 4500;
return acceptAirJamAction({ cooldownMs });
}),
},
}));Use this pattern when the caller needs a real semantic outcome.
Embedded Arcade Runtimes
When a game runs inside Arcade, createAirJamStore still works the same way in game code.
The SDK/runtime automatically resolves the correct replicated store domain from the embedded runtime context, so you should:
- keep normal
createAirJamStore(...)usage in games - not parse Arcade URL params in app/game code
- not pick custom store domains in game code just to work when embedded
API Summary
createAirJamStore<T>(initializer) returns:
- Zustand-compatible hook for selectors.
useActions()dispatch map with() => voidor(payloadObject) => voidsignatures.asPlayer(controllerId)host-only dispatch map for explicit player impersonation of semantic actions.
T must include an actions object with host handlers (ctx, payload) => void, where payload is undefined or a plain object.