Migrating Legacy Games
This guide is for older Air Jam games that still use the pre-v1 app shape.
If you are starting fresh, use Quick Start instead.
When You Need This Guide
You are likely on the old shape if your game still has some of these patterns:
AirJamProviderinApp.tsx- flat files like
src/host-view.tsx,src/controller-view.tsx, andsrc/store.ts HostShell/ControllerShell- old store actions like
joinTeam(team, playerId?) - controller code that calls
controller.sendInput(...)directly in a loop
The v1 goal is not “make old code barely compile”.
The goal is:
- move to the canonical v1 runtime shape
- keep boundaries clean between runtime, replicated state, and game logic
- remove old shell/provider assumptions instead of carrying them forward
Prerelease Local Migration Note
If you are migrating an old game against a local unreleased Air Jam workspace before packages are published, keep the setup split clean:
- install
@air-jam/sdkfrom the local package or a local tarball - do not depend on
@air-jam/serverthrough a directfile:package reference if it still carries monorepo-onlyworkspace:*internals - for prerelease local dev, resolve
air-jam-serverfrom your nearby Air Jam workspace checkout or use tarballs once you are doing package validation
This is only a prerelease migration concern. The release goal is published packages or tarball validation, not permanent cross-repo path coupling.
The Target Shape
The current canonical app shape looks like this:
src/
airjam.config.ts
app.tsx
routes/
host-view.tsx
controller-view.tsx
game/
input.ts
store.ts
...Not every migrated game must match that exact folder tree.
What matters is the boundary model:
createAirJamApp(...)defines runtime and input configapp.tsxowns route-level<airjam.Host>and<airjam.Controller>wrappers- host/controller runtime owner hooks mount only in those route entry files
- replicated state uses the current
createAirJamStoreaction contract - host-local simulation stays separate from replicated store state
Migration Checklist
Use this order. Do not jump around randomly.
- Replace
AirJamProviderbootstrap withcreateAirJamApp - Move host/controller ownership into route wrappers
- Migrate replicated store actions to
(ctx, payload) - Remove
HostShell/ControllerShell - Decide what belongs in replicated store state versus host-local simulation
- Modernize controller input only if the game actually has continuous input
- Clean up old shell/theme workarounds and dead legacy branches
- Re-run local validation before changing game behavior
Step 1: Move Runtime Setup Into airjam.config.ts
Old shape:
import { AirJamProvider } from "@air-jam/sdk";
import { gameInputSchema } from "./types";
export function App() {
return (
<AirJamProvider
serverUrl={import.meta.env.VITE_AIR_JAM_SERVER_URL}
publicHost={import.meta.env.VITE_AIR_JAM_PUBLIC_HOST}
input={{
schema: gameInputSchema,
latch: {
booleanFields: ["action"],
},
}}
>
...
</AirJamProvider>
);
}Target shape:
import { createAirJamApp, env } from "@air-jam/sdk";
import { gameInputSchema } from "./game/input";
export const airjam = createAirJamApp({
runtime: env.vite(import.meta.env),
controllerPath: "/controller",
input: {
schema: gameInputSchema,
},
});This removes runtime wiring noise from your app tree and gives you one canonical config surface.
Step 2: Move Host And Controller Ownership Into app.tsx
Old shape:
<Routes>
<Route path="/" element={<HostView />} />
<Route path="/controller" element={<ControllerView />} />
</Routes>Target shape:
import { Route, Routes } from "react-router-dom";
import { airjam } from "./airjam.config";
export function App() {
return (
<Routes>
<Route
path="/"
element={
<airjam.Host>
<HostView />
</airjam.Host>
}
/>
<Route
path={airjam.paths.controller}
element={
<airjam.Controller>
<ControllerView />
</airjam.Controller>
}
/>
</Routes>
);
}This is the runtime boundary. Keep it explicit.
Step 3: Fix Store Actions First
This is the most important migration after bootstrap.
Old actions often looked like this:
actions: {
joinTeam: (team, playerId) => {
if (!playerId) return;
...
},
setReady: (ready, playerId) => {
if (!playerId) return;
...
},
}The current action model is:
The src/game/stores/game-store.ts path below is a starter-friendly example.
If your project already uses a different store module layout, keep the same action contract and ownership rules while migrating.
actions: {
joinTeam: ({ actorId, connectedPlayerIds }, { team }) =>
set((state) => {
if (!actorId) return state;
...
}),
setReady: ({ actorId }, { ready }) =>
set((state) => {
if (!actorId) return state;
...
}),
}Controller-side dispatch should always look like:
const actions = useGameStore.useActions();
actions.joinTeam({ team: "team1" });
actions.setReady({ ready: true });Do not keep trailing playerId arguments around.
For the full store model, see Networked State.
Step 4: Remove HostShell And ControllerShell
Treat the old shell components as migration targets, not as permanent app structure.
Why:
- they hide app-owned layout inside generic wrappers
- they can leak presentational concerns into the app
- they make theme and layout cleanup harder than it should be
Use app-owned layout instead.
If you still need a narrow layout helper, use one intentionally, for example SurfaceViewport for shared full-screen controller or host UI scaling.
Do not rebuild your app around a generic shell.
Step 5: Choose The Right Migration Branch
Not every legacy game should be migrated the same way.
Branch A: Flat Legacy App
If your app still looks like:
src/
App.tsx
host-view.tsx
controller-view.tsx
store.ts
types.tsthen do the full cleanup:
- add
src/airjam.config.ts - rename to
src/app.tsx - move runtime entry files to
src/routes/ - move input/store/domain files into
src/game/
This is the cleanest migration for older minimal apps.
Branch B: Already Modular Legacy App
If your app already has good structure like:
src/
features/
store/
hooks/
components/then do not force a cosmetic rewrite just to match a template.
Instead:
- modernize bootstrap and route ownership
- modernize the store action contract
- keep healthy feature/domain modules mostly where they are
The migration guide is about boundaries, not folder cosplay.
Branch C: Host-Simulation-Heavy App
If most of the game logic lives in a host-side hook or engine module, keep that boundary explicit:
- replicated store state owns shared, synchronized state
- host-local hook/engine owns timers, refs, physics, media loading, and simulation details
- only the minimum necessary state should cross between them
This is usually cleaner than trying to stuff the whole game loop into the replicated store.
Step 6: Migrate Input Based On Input Type
Do not overgeneralize input migration.
Continuous Input Games
If your controller sends directional or analog-style input every frame, prefer:
- local refs/state
useInputWriter()useControllerTick(...)
Example:
import {
useAirJamController,
useControllerTick,
useInputWriter,
} from "@air-jam/sdk";
import { useRef } from "react";
export function ControllerView() {
const controller = useAirJamController();
const writeInput = useInputWriter();
const movementRef = useRef({ x: 0, y: 0 });
const actionRef = useRef(false);
useControllerTick(
() => {
writeInput({
movementX: movementRef.current.x,
movementY: movementRef.current.y,
action: actionRef.current,
});
},
{
enabled:
controller.connectionStatus === "connected" &&
controller.runtimeState === "playing",
intervalMs: 16,
},
);
}Action-Only Or Choice-Driven Games
If the controller mainly sends taps, choices, or store actions, do not add per-frame input machinery just because another game needed it.
Use the simpler model that matches your game.
For more input details, see Input System.
Step 7: Clean Up Old latch Usage Carefully
Older games may still configure input through latch.
For most games, the right migration is smaller than you think:
- move the schema into
airjam.config.ts - remove
latchentirely - keep the default input behavior unless you have a real non-default need
Only add input.behavior overrides when the defaults are not enough.
Step 8: Remove Legacy Workarounds
After bootstrap, shell, and action migration, remove old hacks that only existed because of the old shape.
Common examples:
- theme fixes that fight shell-injected classes
- old child-mode or shell-mode presentation branches
- duplicated route aliases like
/hostunless you truly still need them - stale README instructions that still describe the old app shape
This step matters. A migration is not complete if the old assumptions are still quietly shaping the code.
Identity Rules To Recheck
During migration, be explicit about identity.
You should know:
- what is the Air Jam controller/session identity
- what is the in-game avatar or character identity
- which store keys use which identity
Do not leave this implicit.
This becomes especially important when:
- controllers are assigned to in-game characters
- bots exist
- host simulation uses different IDs than controller presence
Validation Checklist
Before calling a migration done, verify:
- host route boots cleanly with
<airjam.Host> - controller route joins cleanly with
<airjam.Controller> - store actions no longer rely on trailing
playerIdarguments - no child component mounts a second runtime owner hook
- shell-removal cleanup is complete
- README and local dev commands match the migrated app shape
pnpm run typecheckpnpm run build- the game still behaves correctly in local host/controller play
Recommended Order For Real Migrations
When migrating multiple games, do them in this order:
- smallest flat app first
- host-simulation-heavy app second
- already-modular app third
That order usually sharpens the guide fastest:
- first app proves the basic recipe
- second app forces the harder boundary decisions
- third app benefits from the refined guide and should migrate more cleanly