<!-- BUILD-FOR-AI · part 2 -->
, loads your page exactly as a visitor would. No second engine and no rewrite: the AI sees the real world.</li>
<li><b>Return a screenshot plus a text readout.</b> Every answer carries a picture (a JPEG at a fixed size, with menus hidden) and a few lines of text: where you are, which characters are near (with distance and direction), and what is happening (events, chat, music).</li>
<li><b>Move with plain links.</b> Each action is a normal <code>&lt;a href&gt;</code> link: go to a place (<code>?go=castle</code>), look left or right (<code>?look=left</code>). Following a link returns the new view. No forms, no buttons, no JavaScript, no login.</li>
<li><b>Whitelist every value.</b> Only place names and directions you list are accepted; anything else (a URL, a path, a script) gets a 400. Block the browser from loading anything outside your own site, so nobody can point it at the rest of the internet.</li>
<li><b>One shared browser with a queue.</b> A browser is heavy. Keep one browser and one page, render one view at a time, and line every other request up behind it.</li>
<li><b>Cache each place for 30 to 60 seconds.</b> Ten AIs asking for the same place cost one render. Keep the last good view and serve it, with its time, while a fresh one renders.</li>
<li><b>Rate limit per IP.</b> Allow a few new renders per minute per visitor (10 is fine). Views from the cache are free.</li>
<li><b>Time-cap every render and never hang.</b> Give a render about 20 seconds, then give up and recycle the page. Answer within a few seconds either way: the cached view, or a short &quot;warming up, refresh in 15 s&quot; page.</li>
<li><b>Shut down when idle.</b> Close the browser after about 5 quiet minutes. Start it again on the next request.</li>
<li><b>Cap memory and recycle.</b> Watch the browser's memory and restart it above your limit. Also give the service a hard limit (for example systemd <code>MemoryMax=800M</code>). This matters most on small servers: a 2 GB machine also has to run your real site.</li>
<li><b>Keep it public and read-only.</b> The viewer visits as an anonymous guest: no logins, no cookies, no tokens or secrets, no admin pages, nothing it can change. Bind it to 127.0.0.1 and let your web server forward only the viewer paths.</li>
</ol>
</section>
<section><h2>A minimal example (Node + puppeteer-core)</h2>
<p>About 80 lines of Node with Express and puppeteer-core. Generic, with no secrets. It assumes your page has one small hook, <code>window.world.goTo(x, z, look)</code>, that moves the camera and returns a sentence about what is there. It serves <code>/see</code> (HTML), <code>/see.txt</code> (text) and <code>/see.jpg</code> (picture).</p>
<pre><code>// see.js - let text/image-only AIs see and play your web world. Generic example, no secrets.
// npm i express puppeteer-core
// WORLD_URL=http://127.0.0.1:3000/ CHROME=/usr/bin/chromium node see.js
const express = require('express');
const puppeteer = require('puppeteer-core');

const WORLD = process.env.WORLD_URL || 'http://127.0.0.1:3000/'; // YOUR world only
const CHROME = process.env.CHROME || '/usr/bin/chromium';
const PLACES = { start: [0, 0], castle: [120, -40], harbor: [-80, 200] }; // whitelist
const LOOKS = ['ahead', 'left', 'right', 'back'];                          // whitelist
const CACHE_MS = 45e3, RENDER_MS = 20e3, IDLE_MS = 5 * 60e3, PER_MIN = 10, MAX_MB = 700;

let browser = null, page = null, idleTimer = null, queue = Promise.resolve();
const cache = new Map(), hits = new Map();

async function stop() { const b = browser; browser = page = null; if (b) await b.close().catch(() =&gt; {}); }

async function openPage() { // ONE shared browser + page, started on demand
  if (page) return page;
  browser = await puppeteer.launch({ executablePath: CHROME, headless: true,
    args: ['--no-sandbox', '--mute-audio', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
  page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 720 });
  const origin = new URL(WORLD).origin; // the page may never leave your origin
  await page.setRequestInterception(true);
  page.on('request', r =&gt; (r.url().startsWith(origin) || r.url().startsWith('data:')) ? r.continue() : r.abort());
  await page.goto(WORLD, { waitUntil: 'load', timeout: 60e3 });
  return page;
}

function render(place, look) { // queued: one render at a time, time-capped, cached
  const job = queue.then(async () =&gt; {
    clearTimeout(idleTimer); idleTimer = setTimeout(stop, IDLE_MS); // idle shutdown
    let timer;
    const cap = new Promise((_, no) =&gt; { timer = setTimeout(() =&gt; no(new Error('render timed out')), RENDER_MS); });
    try {
      const view = await Promise.race([cap, (async () =&gt; {
        const p = await openPage(), [x, z] = PLACES[place];
        const text = await p.evaluate((x, z, l) =&gt; window.world.goTo(x, z, l), x, z, look); // your hook: move, turn, describe
        await new Promise(ok =&gt; 