ai-townCode Deep DiveEngine Controller

File Analysis: convex/aiTown/main.ts

This file is the engine controller for the entire AI Town simulation. It’s responsible for managing the lifecycle of the game loop, making it the “ignition system” that powers the virtual world.

1. Core Role and Positioning

This file acts as the central control panel for the AI Town simulation engine. Its primary responsibility is to manage the lifecycle of the game loop for each virtual world, including creating, starting, stopping, and ensuring its continuous operation. It is the “ignition system” that powers the entire simulation.

2. Core Value and Process

The core value of this file is to provide a persistent and robust mechanism that drives the simulation forward in discrete time steps. It ensures that the virtual world is always “alive” and processing agent actions.

The process begins when an engine is started for a world. This action schedules a background task, runStep, to begin executing. This task runs in a loop for a short duration, advancing the game state tick by tick. Before it finishes, it schedules itself to run again immediately, creating a continuous, self-perpetuating cycle that keeps the simulation running. This loop can be gracefully stopped or forcibly restarted (“kicked”) through dedicated functions.

3. Code Deep Dive

createEngine(ctx)

This function initializes a new simulation engine. It records the current time (now) and inserts a new entry into the engines database table. This new entry is configured with the currentTime set to now, a generationNumber of 0 (indicating it’s the first version), and a running status set to true. It then returns the ID of this newly created engine record.

export async function createEngine(ctx: MutationCtx) {
  const now = Date.now();
  const engineId = await ctx.db.insert('engines', {
    currentTime: now,
    generationNumber: 0,
    running: true,
  });
  return engineId;
}

loadWorldStatus(db, worldId)

This is a helper function designed to find the connection between a world and its engine. It queries the worldStatus table using a specific index (worldId) to efficiently find the unique record associated with the given worldId. If no such record exists, it throws an error, as every world should have a corresponding status record. If found, it returns the worldStatus object, which contains the engineId.

async function loadWorldStatus(db: DatabaseReader, worldId: Id<'worlds'>) {
  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}`);
  }
  return worldStatus;
}

startEngine(ctx, worldId)

This function acts as the ignition for a world’s simulation. First, it uses loadWorldStatus to find the associated engineId. It then fetches the engine’s data. It performs two checks: first, that the engine actually exists, and second, that the engine is not already running. If it’s already running, it throws an error. If the checks pass, it “wakes up” the engine by patching its database record. It updates the currentTime to the present moment, sets running to true, and increments the generationNumber. This increment is critical for ensuring any old, lingering loops are invalidated. Finally, it uses the ctx.scheduler to immediately schedule the runStep background task, passing it the worldId and the new generationNumber.

export async function startEngine(ctx: MutationCtx, worldId: Id<'worlds'>) {
  const { engineId } = await loadWorldStatus(ctx.db, worldId);
  const engine = await ctx.db.get(engineId);
  if (!engine) {
    throw new Error(`Invalid engine ID: ${engineId}`);
  }
  if (engine.running) {
    throw new Error(`Engine ${engineId} isn't currently stopped`);
  }
  const now = Date.now();
  const generationNumber = engine.generationNumber + 1;
  await ctx.db.patch(engineId, {
    // Forcibly advance time to the present. This does mean we'll skip
    // simulating the time the engine was stopped, but we don't want
    // to have to simulate a potentially large stopped window and send
    // it down to clients.
    lastStepTs: engine.currentTime,
    currentTime: now,
    running: true,
    generationNumber,
  });
  await ctx.scheduler.runAfter(0, internal.aiTown.main.runStep, {
    worldId: worldId,
    generationNumber,
    maxDuration: ENGINE_ACTION_DURATION,
  });
}

kickEngine(ctx, worldId)

This function is a “force restart” for an already running engine. Like startEngine, it finds the engine and verifies it exists. However, it checks that the engine is currently running. If so, it increments the generationNumber and patches this new value into the database. This action effectively invalidates the currently executing runStep loop. It then immediately schedules a new runStep to take over, ensuring the simulation continues with a fresh start.

export async function kickEngine(ctx: MutationCtx, worldId: Id<'worlds'>) {
  const { engineId } = await loadWorldStatus(ctx.db, worldId);
  const engine = await ctx.db.get(engineId);
  if (!engine) {
    throw new Error(`Invalid engine ID: ${engineId}`);
  }
  if (!engine.running) {
    throw new Error(`Engine ${engineId} isn't currently running`);
  }
  const generationNumber = engine.generationNumber + 1;
  await ctx.db.patch(engineId, { generationNumber });
  await ctx.scheduler.runAfter(0, internal.aiTown.main.runStep, {
    worldId: worldId,
    generationNumber,
    maxDuration: ENGINE_ACTION_DURATION,
  });
}

stopEngine(ctx, worldId)

This is the “off switch” for the simulation. It finds the engine associated with the worldId, confirms it exists and is currently running, and then performs a single database patch operation to set the running flag to false. The runStep loop is designed to check this flag and will terminate itself gracefully on its next iteration.

export async function stopEngine(ctx: MutationCtx, worldId: Id<'worlds'>) {
  const { engineId } = await loadWorldStatus(ctx.db, worldId);
  const engine = await ctx.db.get(engineId);
  if (!engine) {
    throw new Error(`Invalid engine ID: ${engineId}`);
  }
  if (!engine.running) {
    throw new Error(`Engine ${engineId} isn't currently running`);
  }
  await ctx.db.patch(engineId, { running: false });
}

runStep

This is the core of the simulation’s continuous execution, defined as a Convex internalAction. It’s designed to run in a continuous, self-perpetuating cycle.

Execution Flow:

  1. Load State: It first attempts to load the world and game state by calling internal.aiTown.game.loadWorld. This query has a built-in check: it will only succeed if the generationNumber passed to runStep matches the one currently in the database.
  2. Instantiate Game: If the state is loaded successfully, it creates a new instance of the Game class, which contains the actual step-by-step simulation logic.
  3. Simulation Loop: It enters a while loop that continues as long as the current time is less than a calculated deadline (which is now + maxDuration). Inside the loop:
    • It calls game.runStep() to process a single tick of the simulation.
    • It then calculates how long to wait until the next tick should occur and calls sleep() to pause execution.
  4. Reschedule: Once the while loop finishes (its time slice is up), it schedules itself to run again immediately by calling ctx.scheduler.runAfter(0, ...) with the same worldId.
  5. Error Handling: The entire process is wrapped in a try...catch block. It specifically catches ConvexErrors related to the engine not running or a generationNumber mismatch. In these cases, it logs a debug message and exits silently, which is the expected way for the loop to be stopped by stopEngine or kickEngine. Any other unexpected errors are thrown to be handled elsewhere.
export const runStep = internalAction({
  args: {
    worldId: v.id('worlds'),
    generationNumber: v.number(),
    maxDuration: v.number(),
  },
  handler: async (ctx, args) => {
    try {
      const { engine, gameState } = await ctx.runQuery(internal.aiTown.game.loadWorld, {
        worldId: args.worldId,
        generationNumber: args.generationNumber,
      });
      const game = new Game(engine, args.worldId, gameState);
 
      let now = Date.now();
      const deadline = now + args.maxDuration;
      while (now < deadline) {
        await game.runStep(ctx, now);
        const sleepUntil = Math.min(now + game.stepDuration, deadline);
        await sleep(sleepUntil - now);
        now = Date.now();
      }
      await ctx.scheduler.runAfter(0, internal.aiTown.main.runStep, {
        worldId: args.worldId,
        generationNumber: game.engine.generationNumber,
        maxDuration: args.maxDuration,
      });
    } catch (e: unknown) {
      if (e instanceof ConvexError) {
        if (e.data.kind === 'engineNotRunning') {
          console.debug(`Engine is not running: ${e.message}`);
          return;
        }
        if (e.data.kind === 'generationNumber') {
          console.debug(`Generation number mismatch: ${e.message}`);
          return;
        }
      }
      throw e;
    }
  },
});

sendInput(ctx, args)

This is a mutation function, making it an externally callable endpoint that can modify the database. Its purpose is to allow clients (like the frontend) to submit actions into the simulation. It receives a worldId, the name of the action (e.g., ‘startConversation’), and an args object containing the parameters for that action. It then passes this information directly to the insertInput function, which handles the logic of creating a new inputs record in the database for the engine to process later.

export const sendInput = mutation({
  args: {
    worldId: v.id('worlds'),
    name: v.string(),
    args: v.any(),
  },
  handler: async (ctx, args) => {
    return await insertInput(ctx, args.worldId, args.name as any, args.args);
  },
});

inputStatus(ctx, args)

This is a query function, an externally callable read-only endpoint. It’s used by clients to check on the status of an action they previously submitted via sendInput. It takes an inputId and retrieves the corresponding record from the inputs table. It then returns the returnValue field of that record, which contains the result of the completed action. If no value is present yet, it returns null.

export const inputStatus = query({
  args: {
    inputId: v.id('inputs'),
  },
  handler: async (ctx, args) => {
    const input = await ctx.db.get(args.inputId);
    if (!input) {
      throw new Error(`Invalid input ID: ${args.inputId}`);
    }
    return input.returnValue ?? null;
  },
});