File Analysis: convex/aiTown/game.ts

This file is the Game State Manager, the central nervous system of AI Town. It defines the Game class, which orchestrates the simulation’s “heartbeat,” turning static database records into a living, evolving world.

1. Core Role and Positioning

This file acts as the central Game State Manager for the AI Town simulation. It defines the Game class, which implements the core logic of the simulation loop, manages the state of all game objects (players, agents, world map), and handles loading from and saving to the database. It is the concrete implementation of the abstract game engine, tailored specifically for the rules and entities of AI Town.

2. Core Value and Process

The primary value of this file is to orchestrate the “heartbeat” of the virtual world. It takes the static data from the database and brings it to life by executing a step-by-step simulation. It defines how characters move, how conversations evolve, and how AI agents are triggered to think. The process ensures that the world’s state is consistently updated and persisted, allowing the simulation to run continuously.

The core flow is driven by an external process (from main.ts) that executes a “step” of the simulation, which is composed of many small “ticks”.

3. Code Deep Dive

Class: Game

The Game class is the engine of the AI Town simulation. It’s instantiated for each simulation “step”. Its properties hold the entire active state of the world in memory: world contains all the players and agents, worldMap holds the map layout, and playerDescriptions and agentDescriptions store detailed information like character backstories. It also has configuration like tickDuration and stepDuration that define the simulation’s timing.

export class Game extends AbstractGame {
  tickDuration = 16;
  stepDuration = 1000;
  maxTicksPerStep = 600;
  maxInputsPerStep = 32;
 
  world: World;
 
  historicalLocations: Map<GameId<'players'>, HistoricalObject<Location>>;
 
  descriptionsModified: boolean;
  worldMap: WorldMap;
  playerDescriptions: Map<GameId<'players'>, PlayerDescription>;
  agentDescriptions: Map<GameId<'agents'>, AgentDescription>;
 
  pendingOperations: Array<{ name: string; args: any }> = [];
 
  numPathfinds: number;
 
  constructor(
    engine: Doc<'engines'>,
    public worldId: Id<'worlds'>,
    state: GameState,
  ) {
    super(engine);
 
    this.world = new World(state.world);
    delete this.world.historicalLocations;
 
    this.descriptionsModified = false;
    this.worldMap = new WorldMap(state.worldMap);
    this.agentDescriptions = parseMap(state.agentDescriptions, AgentDescription, (a) => a.agentId);
    this.playerDescriptions = parseMap(
      state.playerDescriptions,
      PlayerDescription,
      (p) => p.playerId,
    );
 
    this.historicalLocations = new Map();
 
    this.numPathfinds = 0;
  }
// ... rest of the class methods

Static Method: Game.load(db, worldId, generationNumber)

This function is the entry point for starting a simulation step. Its job is to gather all the required data from the database and assemble it into a single, ready-to-use GameState object. It performs a sequence of database queries:

  1. It fetches the main worldDoc which contains the current state of all players and agents.
  2. It finds the associated worldStatus to get the engineId, which is needed to load the simulation engine’s state.
  3. It collects all playerDescriptions and agentDescriptions for the given world. It’s careful to filter out any descriptions for players or agents that no longer exist in the main worldDoc.
  4. It fetches the worldMapDoc for the world’s geography.
  5. Finally, it bundles all this data into a gameState object and returns it along with the engine document. This object is then used to construct a new Game instance.
  static async load(
    db: DatabaseReader,
    worldId: Id<'worlds'>,
    generationNumber: number,
  ): Promise<{ engine: Doc<'engines'>; gameState: GameState }> {
    const worldDoc = await db.get(worldId);
    if (!worldDoc) {
      throw new Error(`No world found with id ${worldId}`);
    }
    const worldStatus = await db
      .query('worldStatus')
      .withIndex('worldId', (q) => q.eq('worldId', worldId))
      .unique();
    if (!worldStatus) {
      throw new Error(`No engine found for world ${worldId}`);
    }
    const engine = await loadEngine(db, worldStatus.engineId, generationNumber);
    const playerDescriptionsDocs = await db
      .query('playerDescriptions')
      .withIndex('worldId', (q) => q.eq('worldId', worldId))
      .collect();
    const agentDescriptionsDocs = await db
      .query('agentDescriptions')
      .withIndex('worldId', (q) => q.eq('worldId', worldId))
      .collect();
    const worldMapDoc = await db
      .query('maps')
      .withIndex('worldId', (q) => q.eq('worldId', worldId))
      .unique();
    if (!worldMapDoc) {
      throw new Error(`No map found for world ${worldId}`);
    }
    // Discard the system fields and historicalLocations from the world state.
    const { _id, _creationTime, historicalLocations: _, ...world } = worldDoc;
    const playerDescriptions = playerDescriptionsDocs
      // Discard player descriptions for players that no longer exist.
      .filter((d) => !!world.players.find((p) => p.id === d.playerId))
      .map(({ _id, _creationTime, worldId: _, ...doc }) => doc);
    const agentDescriptions = agentDescriptionsDocs
      .filter((a) => !!world.agents.find((p) => p.id === a.agentId))
      .map(({ _id, _creationTime, worldId: _, ...doc }) => doc);
    const {
      _id: _mapId,
      _creationTime: _mapCreationTime,
      worldId: _mapWorldId,
      ...worldMap
    } = worldMapDoc;
    return {
      engine,
      gameState: {
        world,
        playerDescriptions,
        agentDescriptions,
        worldMap,
      },
    };
  }

Method: tick(now)

This method represents a single, discrete moment in the game world. It is called many times within a single simulation step. Its purpose is to update the state of every dynamic entity in the world in a deterministic order, preventing inconsistencies.

💡

The flow is as follows:

  1. Player Updates: It uses a three-phase update for players. First, player.tick() handles internal logic. Second, player.tickPathfinding() calculates the path. Third, player.tickPosition() actually moves the player. This separation ensures that all players decide their actions for the tick based on the same initial world state.
  2. Conversation Updates: It then ticks all ongoing conversations.
  3. Agent Updates: It ticks all agents, which is where an AI might decide it needs to “think” or start a new conversation.
  4. History Recording: At the very end, it captures the final position of every player.
  tick(now: number) {
    for (const player of this.world.players.values()) {
      player.tick(this, now);
    }
    for (const player of this.world.players.values()) {
      player.tickPathfinding(this, now);
    }
    for (const player of this.world.players.values()) {
      player.tickPosition(this, now);
    }
    for (const conversation of this.world.conversations.values()) {
      conversation.tick(this, now);
    }
    for (const agent of this.world.agents.values()) {
      agent.tick(this, now);
    }
 
    // Save each player's location into the history buffer at the end of
    // each tick.
    for (const player of this.world.players.values()) {
      let historicalObject = this.historicalLocations.get(player.id);
      if (!historicalObject) {
        historicalObject = new HistoricalObject(locationFields, playerLocation(player));
        this.historicalLocations.set(player.id, historicalObject);
      }
      historicalObject.update(now, playerLocation(player));
    }
  }

Method: takeDiff()

This method is called at the end of a simulation step. Its job is to package up all the changes that happened during the step into a compact GameStateDiff object. It serializes the new state of objects that have changed, including player movement history and any pending AI operations. This “diff” is much smaller than the entire game state, making database updates more efficient.

  takeDiff(): GameStateDiff {
    const historicalLocations = [];
    let bufferSize = 0;
    for (const [id, historicalObject] of this.historicalLocations.entries()) {
      const buffer = historicalObject.pack();
      if (!buffer) {
        continue;
      }
      historicalLocations.push({ playerId: id, location: buffer });
      bufferSize += buffer.byteLength;
    }
    if (bufferSize > 0) {
      console.debug(
        `Packed ${Object.entries(historicalLocations).length} history buffers in ${(
          bufferSize / 1024
        ).toFixed(2)}KiB.`,
      );
    }
    this.historicalLocations.clear();
 
    const result: GameStateDiff = {
      world: { ...this.world.serialize(), historicalLocations },
      agentOperations: this.pendingOperations,
    };
    this.pendingOperations = [];
    if (this.descriptionsModified) {
      result.playerDescriptions = serializeMap(this.playerDescriptions);
      result.agentDescriptions = serializeMap(this.agentDescriptions);
      result.worldMap = this.worldMap.serialize();
      this.descriptionsModified = false;
    }
    return result;
  }

Static Method: Game.saveDiff(ctx, worldId, diff)

This is a critical database mutation that applies the changes from a simulation step.

  1. It compares the new state with the existing state to identify removed entities (players, conversations, etc.).
  2. Instead of deleting them, it archives them into separate tables (e.g., archivedPlayers), preserving a complete history of the world.
  3. It replaces the main world document with the new version from the diff.
  4. Finally, it triggers the pending AI agent operations (runAgentOperation), handing off control to the AI’s “thinking” process.
  static async saveDiff(ctx: MutationCtx, worldId: Id<'worlds'>, diff: GameStateDiff) {
    const existingWorld = await ctx.db.get(worldId);
    if (!existingWorld) {
      throw new Error(`No world found with id ${worldId}`);
    }
    const newWorld = diff.world;
    // Archive newly deleted players, conversations, and agents.
    for (const player of existingWorld.players) {
      if (!newWorld.players.some((p) => p.id === player.id)) {
        await ctx.db.insert('archivedPlayers', { worldId, ...player });
      }
    }
    for (const conversation of existingWorld.conversations) {
      if (!newWorld.conversations.some((c) => c.id === conversation.id)) {
        const participants = conversation.participants.map((p) => p.playerId);
        // ... (Archiving logic)
      }
    }
    for (const conversation of existingWorld.agents) {
      if (!newWorld.agents.some((a) => a.id === conversation.id)) {
        await ctx.db.insert('archivedAgents', { worldId, ...conversation });
      }
    }
    // Update the world state.
    await ctx.db.replace(worldId, newWorld);
 
    // ... (Update descriptions & map logic)
 
    // Start the desired agent operations.
    for (const operation of diff.agentOperations) {
      await runAgentOperation(ctx, operation.name, operation.args);
    }
  }

Functions: loadWorld and saveWorld

These two functions are simple wrappers that make the Game class’s logic available to the wider Convex backend system. loadWorld is an internalQuery for fetching the game state, and saveWorld is an internalMutation for applying the engine updates and saving the state diff. They serve as the official, type-safe API for interacting with the game simulation loop.

export const loadWorld = internalQuery({
  args: {
    worldId: v.id('worlds'),
    generationNumber: v.number(),
  },
  handler: async (ctx, args) => {
    return await Game.load(ctx.db, args.worldId, args.generationNumber);
  },
});
 
export const saveWorld = internalMutation({
  args: {
    engineId: v.id('engines'),
    engineUpdate,
    worldId: v.id('worlds'),
    worldDiff: gameStateDiff,
  },
  handler: async (ctx, args) => {
    await applyEngineUpdate(ctx, args.engineId, args.engineUpdate);
    await Game.saveDiff(ctx, args.worldId, args.worldDiff);
  },
});