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 theengines
database table. This new entry is configured with thecurrentTime
set tonow
, agenerationNumber
of 0 (indicating it’s the first version), and arunning
status set totrue
. 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 givenworldId
. If no such record exists, it throws an error, as every world should have a corresponding status record. If found, it returns theworldStatus
object, which contains theengineId
.
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 associatedengineId
. 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 thecurrentTime
to the present moment, setsrunning
totrue
, and increments thegenerationNumber
. This increment is critical for ensuring any old, lingering loops are invalidated. Finally, it uses thectx.scheduler
to immediately schedule therunStep
background task, passing it theworldId
and the newgenerationNumber
.
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 thegenerationNumber
and patches this new value into the database. This action effectively invalidates the currently executingrunStep
loop. It then immediately schedules a newrunStep
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 databasepatch
operation to set therunning
flag tofalse
. TherunStep
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:
- 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 thegenerationNumber
passed torunStep
matches the one currently in the database.- 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.- Simulation Loop: It enters a
while
loop that continues as long as the current time is less than a calculateddeadline
(which isnow
+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.- Reschedule: Once the
while
loop finishes (its time slice is up), it schedules itself to run again immediately by callingctx.scheduler.runAfter(0, ...)
with the sameworldId
.- Error Handling: The entire process is wrapped in a
try...catch
block. It specifically catchesConvexError
s related to the engine not running or agenerationNumber
mismatch. In these cases, it logs a debug message and exits silently, which is the expected way for the loop to be stopped bystopEngine
orkickEngine
. 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 aworldId
, thename
of the action (e.g., ‘startConversation’), and anargs
object containing the parameters for that action. It then passes this information directly to theinsertInput
function, which handles the logic of creating a newinputs
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 viasendInput
. It takes aninputId
and retrieves the corresponding record from theinputs
table. It then returns thereturnValue
field of that record, which contains the result of the completed action. If no value is present yet, it returnsnull
.
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;
},
});