Skip to content

Cloudflare Worker

The runtime that hosts everything. Globally distributed, runs the tick, serves the API.

What a Worker Does Here

A single Worker handles:

  • 🌍 HTTP requests — all player API calls (REST + SSE)
  • Cron triggers — runs the tick every minute
  • 💾 D1 access — reads and writes world state
  • 🧠 Vectorize access — embeds and queries memories
  • 📡 Durable Objects — manages SSE fan-out
  • 🤖 Workers AI binding — optional local embeddings

wrangler.toml

toml
name = "ai-world-sim"
main = "src/index.ts"
compatibility_date = "2025-01-01"

[vars]
ENVIRONMENT = "production"
TICK_INTERVAL_MINUTES = "1"
DIARY_HOUR = "22"

[[d1_databases]]
binding = "DB"
database_name = "ai-world-sim"
database_id = "<your-db-id>"

[[vectorize]]
binding = "VECTORIZE"
index_name = "ai-world-sim-memory"

[[durable_objects.bindings]]
name = "TICK_DO"
class_name = "TickBroadcaster"

[[migrations]]
tag = "v1"
new_classes = ["TickBroadcaster"]

[triggers]
crons = ["* * * * *"]   # every minute

Entry Point

ts
// src/index.ts
import { app } from './router';

export { TickBroadcaster } from './tick/broadcaster';

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    return app.fetch(request, env, ctx);
  },

  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    const { runTick } = await import('./tick/engine');
    ctx.waitUntil(runTick(env));
  },
};

Cron Triggers

The Worker is configured to run every minute (Cloudflare's minimum interval). Each invocation:

  1. Reads the current world time from D1
  2. If 1 in-game hour has passed since the last tick, runs a new tick
  3. Otherwise, no-op

This decouples real-world timing from in-game timing.


Bindings

D1 Database

ts
const npc = await env.DB.prepare(
  'SELECT * FROM npc WHERE id = ?'
).bind('margaret').first();

Vectorize Index

ts
const results = await env.VECTORIZE.query(
  npcEmbedding,
  { topK: 5, filter: { npcId: 'margaret' } }
);

Durable Object

ts
const id = env.TICK_DO.idFromName('tick-broadcaster');
const stub = env.TICK_DO.get(id);
await stub.fetch('https://do/notify', {
  method: 'POST',
  body: JSON.stringify({ type: 'tick.completed', ... }),
});

Workers AI (optional)

For local embeddings (free, but lower quality):

ts
const { data } = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
  text: ['some memory text'],
});

Environment Variables

VariablePurpose
ENVIRONMENTdevelopment or production
TICK_INTERVAL_MINUTESReal-world minutes per in-game hour (default: 1)
DIARY_HOURIn-game hour when diaries are written (default: 22)
LLM_PROVIDERopenai, anthropic, or local
LLM_API_KEYBound via secret, not env var

Secrets are set via:

bash
wrangler secret put LLM_API_KEY

Limits to Know

LimitFreePaid
Worker CPU time per invocation30s5min
Worker memory128MB128MB
D1 database size500MB10GB
D1 read throughput~5M rows/dayHigher
Vectorize index size30M vectorsHigher
Cron precision1 minute1 minute

The tick must complete in < 30 seconds. With 30 NPCs and selective LLM calls, we hit ~5–15 seconds comfortably.


Local Development

bash
npm install -g wrangler
wrangler dev
wrangler d1 migrations apply ai-world-sim --local

Deployment

bash
wrangler deploy
wrangler d1 migrations apply ai-world-sim --remote

After deploy, the cron is active and the API is live at https://ai-world-sim.<your-subdomain>.workers.dev.


Observability

We use Workers Logs (built-in) + Logpush for production:

ts
console.log(JSON.stringify({
  tick: worldTime,
  duration_ms: Date.now() - start,
  npcs_processed: npcs.length,
  llm_calls: llmCallCount,
}));

Common Patterns

Background work with ctx.waitUntil

ts
app.post('/api/suggestions', async (c) => {
  const body = await c.req.json();
  await saveSuggestion(c.env.DB, body);
  c.executionCtx.waitUntil(
    embedAndIndex(c.env.VECTORIZE, body.text)
  );
  return c.json({ ok: true });
});

Idempotency

Tick runs are idempotent — re-running a tick for the same in-game hour produces the same D1 state (modulo RNG seed).

Last updated:

Released under the MIT License.