Writing Game Adapters

A GameAdapter is the bridge between swarmtest and your game server. It handles WebSocket connection management, message serialization, state tracking, and action mapping. swarmtest ships with adapters for Tipo and PlayerTwo, but you can write your own for any game with a WebSocket protocol.

The GameAdapter Interface

Every adapter must implement the GameAdapter interface:

import type { GameAdapter, AgentConnection, AgentConfig, AvailableAction, ActionDescription, InvariantViolation } from 'swarmtest';

export class MyGameAdapter implements GameAdapter {
  readonly name = 'mygame';

  // Connection
  async connect(url: string, agentId: string): Promise<AgentConnection>;
  createConnectMessage(agentId: string, config: AgentConfig): unknown;
  createDisconnectMessage?(): unknown | null;
  createPingMessage(timestamp: number): unknown;

  // Message parsing
  parseServerMessage(raw: string): unknown;
  serializeClientMessage(msg: unknown): string;
  isConnectAccepted(msg: unknown): boolean;
  isError(msg: unknown): boolean;
  getErrorInfo(msg: unknown): { code: string; message: string } | null;

  // State management
  initAgentState(connectResponse: unknown, agentId: string): unknown;
  updateAgentState(state: unknown, msg: unknown): unknown;

  // Actions
  getAvailableActions(state: unknown): AvailableAction[];
  buildActionMessage(state: unknown, action: string, params?: Record<string, unknown>): unknown | null;

  // Invariants
  checkInvariants(state: unknown): InvariantViolation[];

  // LLM context
  describeGameContext(): string;
  describeAvailableActions(): ActionDescription[];
}

Step-by-Step Guide

1. Create the adapter file

Create a new directory under adapters/:

adapters/
  mygame/
    MyGameAdapter.ts
    behaviors/
      MyExplorer.ts

2. Implement WebSocket connection

The connect method should open a WebSocket and return an AgentConnection:

import WebSocket from 'ws';

async connect(url: string, agentId: string): Promise<AgentConnection> {
  const ws = new WebSocket(url);

  await new Promise<void>((resolve, reject) => {
    ws.once('open', resolve);
    ws.once('error', reject);
  });

  return {
    send: (data: string) => ws.send(data),
    onMessage: (handler) => ws.on('message', (d) => handler(d.toString())),
    onClose: (handler) => ws.on('close', (code, reason) => handler(code, reason.toString())),
    onError: (handler) => ws.on('error', handler),
    close: () => ws.close(),
    isConnected: () => ws.readyState === WebSocket.OPEN,
  };
}

3. Handle message serialization

Parse incoming server messages and serialize outgoing client messages:

parseServerMessage(raw: string): unknown {
  return JSON.parse(raw);
}

serializeClientMessage(msg: unknown): string {
  return JSON.stringify(msg);
}

4. Define agent state

The adapter manages each agent’s local state. initAgentState creates the initial state from the connection response, and updateAgentState applies server messages:

initAgentState(connectResponse: unknown, agentId: string): MyAgentState {
  const data = (connectResponse as any).Connected;
  return {
    playerId: data.player_id,
    room: data.room,
    phase: 'exploring',
    // ... game-specific fields
  };
}

updateAgentState(state: MyAgentState, msg: unknown): MyAgentState {
  const key = Object.keys(msg as object)[0];
  switch (key) {
    case 'RoomUpdate':
      return { ...state, room: (msg as any).RoomUpdate.room };
    case 'BattleStarted':
      return { ...state, phase: 'in_battle' };
    // ... handle all server message types
    default:
      return state;
  }
}

5. Map available actions

Return the list of actions available in the current game state. This drives behavior tree execution:

getAvailableActions(state: MyAgentState): AvailableAction[] {
  const actions: AvailableAction[] = [];

  if (state.phase === 'exploring') {
    actions.push({
      name: 'move',
      description: 'Move in a direction',
      params: {
        direction: { type: 'enum', values: ['north', 'south', 'east', 'west'] },
      },
    });
  }

  if (state.phase === 'in_battle') {
    actions.push({
      name: 'battle_attack',
      description: 'Use an attack move',
      params: {
        move_index: { type: 'number', min: 0, max: 3 },
      },
    });
  }

  return actions;
}

6. Build action messages

Convert high-level action names into actual protocol messages:

buildActionMessage(state: MyAgentState, action: string, params?: Record<string, unknown>): unknown | null {
  switch (action) {
    case 'move':
      return { Move: { direction: params?.direction } };
    case 'battle_attack':
      return { BattleAction: { action_type: 'Attack', move_index: params?.move_index } };
    default:
      return null;
  }
}

7. Define invariants

Invariants are game-specific rules that should always hold true. The InvariantDetector calls checkInvariants periodically:

checkInvariants(state: MyAgentState): InvariantViolation[] {
  const violations: InvariantViolation[] = [];

  if (state.hp < 0) {
    violations.push({
      rule: 'hp_non_negative',
      description: `HP is ${state.hp}, should never be negative`,
      severity: 'bug',
    });
  }

  return violations;
}

8. Provide LLM context

These methods help Claude generate relevant behavior trees:

describeGameContext(): string {
  return 'MyGame is a multiplayer RPG. Players explore rooms, fight monsters, trade items, and complete quests.';
}

describeAvailableActions(): ActionDescription[] {
  return [
    {
      name: 'move',
      description: 'Move to an adjacent room',
      params: { direction: { type: 'enum', values: ['north', 'south', 'east', 'west'] } },
      availableWhen: 'exploring',
    },
    // ...
  ];
}

Registering Your Adapter

Add a case to the getAdapter function in src/cli.ts:

function getAdapter(game: string): GameAdapter {
  switch (game) {
    case 'tipo':
      return new TipoAdapter();
    case 'playertwo':
      return new PlayerTwoAdapter();
    case 'mygame':
      return new MyGameAdapter();
    default:
      console.error(`Unknown game: ${game}. Available: tipo, playertwo, mygame`);
      process.exit(1);
  }
}

Then run:

swarmtest run --game mygame --url ws://localhost:PORT --agents 10