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.ts2. 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