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 minuteEntry 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:
- Reads the current world time from D1
- If 1 in-game hour has passed since the last tick, runs a new tick
- 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
| Variable | Purpose |
|---|---|
ENVIRONMENT | development or production |
TICK_INTERVAL_MINUTES | Real-world minutes per in-game hour (default: 1) |
DIARY_HOUR | In-game hour when diaries are written (default: 22) |
LLM_PROVIDER | openai, anthropic, or local |
LLM_API_KEY | Bound via secret, not env var |
Secrets are set via:
bash
wrangler secret put LLM_API_KEYLimits to Know
| Limit | Free | Paid |
|---|---|---|
| Worker CPU time per invocation | 30s | 5min |
| Worker memory | 128MB | 128MB |
| D1 database size | 500MB | 10GB |
| D1 read throughput | ~5M rows/day | Higher |
| Vectorize index size | 30M vectors | Higher |
| Cron precision | 1 minute | 1 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 --localDeployment
bash
wrangler deploy
wrangler d1 migrations apply ai-world-sim --remoteAfter 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).