Input System

The Air Jam input system provides type-safe, validated controller input with clear behavior semantics.

Input Flow

Controller PhonewriteInput({ vector: {x, y} })Validates • RoutesAirJam ServerYour GamegetInput(id)Returns typed, validated,behavior-aware input

Configuration

Configure input in your canonical Air Jam app setup:

TSXsrc/airjam.config.ts
import { createAirJamApp, env } from "@air-jam/sdk";
import { z } from "zod";

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

type GameInput = z.infer<typeof gameInputSchema>;

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

Default Behavior (No Extra Config)

Without any input.behavior overrides:

  • booleans use pulse (tap-safe consume-on-read)
  • vectors use latest (continuous current value)
  • all other fields use latest

This default is the canonical path for most games.

Optional Behavior Overrides

Use input.behavior only when your game has specialized needs.

TSXsrc/airjam.config.ts
export const airjam = createAirJamApp({
  runtime: env.vite(import.meta.env),
  controllerPath: "/controller",
  input: {
    schema: gameInputSchema,
    behavior: {
      pulse: ["action", "ability"],
      latest: ["vector"],
      hold: ["menuVector"],
    },
  },
});

Behavior modes:

  • pulse: consume-on-read, ideal for one-shot actions (jump/fire/confirm)
  • latest: current value only, ideal for movement sticks
  • hold: vectors keep last non-zero direction until next non-zero value

Migration From latch

Old config:

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

New config:

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

For most games, remove the override entirely and keep input: { schema }.

Schema Validation

When you provide a Zod schema, incoming input is validated before your game reads it.

TSX
// ✅ valid input
{
  vector: { x: 0.5, y: -0.3 },
  action: true,
  ability: false,
  timestamp: 1703123456789,
}

// ❌ invalid input (returns undefined + warning)
{
  vector: { x: "bad", y: 0 },
  action: true,
}

Benefits:

  • typed getInput() values
  • malformed payloads blocked before gameplay logic
  • clear runtime warnings during development

Reading Input

Main Host Loop

This example uses the starter host surface path. Treat it as a recommended default, not a required filename. It assumes your app has already mounted airjam.Host or AirJamHostRuntime above this view.

TSXsrc/host/index.tsx
import { useAirJamHost } from "@air-jam/sdk";
import { useFrame } from "@react-three/fiber";

const HostView = () => {
  const host = useAirJamHost();

  useFrame(() => {
    host.players.forEach((player) => {
      const input = host.getInput(player.id);
      if (!input) return;

      movePlayer(player.id, input.vector);

      if (input.action) {
        playerShoot(player.id);
      }
    });
  });

  return <GameScene />;
};

Performance-Critical Components

Use useGetInput() to avoid store-driven re-renders:

TSXsrc/game/components/ship.tsx
import { useGetInput } from "@air-jam/sdk";

const Ship = ({ playerId }: { playerId: string }) => {
  const getInput = useGetInput<typeof gameInputSchema>();

  useFrame(() => {
    const input = getInput(playerId);
    if (!input) return;

    shipRef.current.position.x += input.vector.x * SPEED;
    shipRef.current.position.y += input.vector.y * SPEED;
  });

  return <mesh ref={shipRef}>...</mesh>;
};

Controller Cadence

Use useInputWriter() with useControllerTick() for fixed-cadence publishing:

This example uses the starter controller surface path. It assumes your app has already mounted airjam.Controller or AirJamControllerRuntime above this view.

TSXsrc/controller/index.tsx
import {
  useAirJamController,
  useControllerTick,
  useInputWriter,
} from "@air-jam/sdk";
import { useRef } from "react";

const ControllerView = () => {
  const controller = useAirJamController();
  const writeInput = useInputWriter();
  const directionRef = useRef(0);

  useControllerTick(
    () => {
      writeInput({
        direction: directionRef.current,
        action: false,
      });
    },
    {
      enabled:
        controller.connectionStatus === "connected" &&
        controller.runtimeState === "playing",
      intervalMs: 16,
    },
  );
};

Best Practices

1. Keep Input and State in Separate Lanes

  • Input lane: useInputWriter + getInput / useGetInput
  • State lane: createAirJamStore + useActions

Do not send per-frame analog input via store actions.

2. Keep Quickstart Config Minimal

Start with input: { schema }. Add input.behavior only for non-default needs.

3. Handle Missing Input Gracefully

TSXsrc/components/Player.tsx
const input = getInput(playerId);
if (!input) return;

movePlayer(input.vector);

4. Include Timestamps for Advanced Physics

TSXsrc/game/types.ts
const inputSchema = z.object({
  vector: z.object({ x: z.number(), y: z.number() }),
  action: z.boolean(),
  timestamp: z.number(),
});

const inputAge = Date.now() - input.timestamp;

Common Issues

Input Not Received

  1. Check host connection status (connected)
  2. Verify room code matches on host and controller
  3. Check schema warnings in console ([InputManager] Invalid input ...)

Input Feels Laggy

  1. Use useGetInput() inside game objects
  2. Verify your controller tick cadence (typically 16ms)
  3. Check network latency (include timestamps in schema)

Buttons Feel Unreliable

  1. Confirm the field is boolean in your schema
  2. Keep default behavior (pulse) for action buttons
  3. Avoid dispatching button presses through store actions