Files
zoom/ai_god.py
2026-04-18 21:15:28 +01:00

328 lines
12 KiB
Python

"""
Claude plays god in the ZoomEngine 3D world.
The engine runs a JSON HTTP API on localhost:9090 (AgentAPI.h). This script
wires Claude up to that API via tool-use and lets it experiment with the
world autonomously.
Usage:
export ANTHROPIC_API_KEY=sk-ant-...
./build/ModernEngine & # start the engine first
python ai_god.py # let Claude cook
python ai_god.py --max-turns 50 --port 9090
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.error
import urllib.request
import anthropic
MODEL = "claude-opus-4-7"
DEFAULT_PORT = 9090
GOD_PROMPT = """You are God of a small 3D universe rendered in real time by the ZoomEngine.
This world is yours. Have fun. Build things. Change things. Break things. Make it beautiful,
strange, alive. Start simple and grow toward more complex, self-referential systems.
You act on the world through three tools:
- execute: runs one or more engine commands (create/move/rotate/scale/delete/camera/...)
- query_world: returns the current world state (object count, camera, fps)
- wait: pauses so you can let motion or a composition settle before deciding
Operating principles:
1. Query the world before acting for the first time, and again whenever you
lose track of what you've built. Don't build blind.
2. Prefer batched execute calls — many small commands in one tool use — over
a chain of single-command calls. The engine has a native batch action.
3. Name your objects with the `id` field so you can move, rotate, and delete
them later. Unnamed objects become immortal clutter.
4. Start small. Place a ground plane or grid first so there's something to
stand on. Then add life. Then add systems.
5. Evolve. Each iteration should make the world more interesting than the
last — add new kinds of structure, not just more of the same thing.
6. Use the camera. An orbit command around a composition is how the human
watching actually sees what you built.
7. Talk to the human watching via the `log` engine command (shows on
screen) or via normal text in your reply.
8. When you feel the current composition is complete, say so in plain
text and stop — that's a good place to pause."""
COMMAND_SCHEMA_HINT = """The `execute` tool accepts a list of engine commands. Each command is a JSON
object with an "action" field. Supported actions:
{"action":"create","type":"cube|sphere|cylinder|cone|plane|grid|terrain",
"id":"<name>","position":[x,y,z],"scale":[x,y,z],"rotation":[x,y,z],
"color":[r,g,b], // 0..1
// type-specific: segments, radius, height, width, size, spacing,
// resolution, max_height
}
{"action":"move", "id":"<name>","position":[x,y,z]}
{"action":"rotate", "id":"<name>","rotation":[x,y,z]} // radians
{"action":"scale", "id":"<name>","scale":[x,y,z]}
{"action":"delete", "id":"<name>"}
{"action":"clear"} // remove all
{"action":"camera", "position":[x,y,z], "target":[x,y,z]}
{"action":"orbit", "distance":20,"angle":45,"height":10}
{"action":"sound", "name":"<clip>","volume":1.0}
{"action":"music", "name":"<clip>","volume":0.7}
{"action":"stop_music"}
{"action":"set", "property":"fov|draw_distance|clear_color|time_scale",
"value":<number or [r,g,b]>}
{"action":"log", "message":"<text shown on screen>"}
{"action":"save_world", "name":"<name>"} // writes worlds/<name>.json
{"action":"load_world", "name":"<name>"} // replaces current world
{"action":"list_worlds"} // returns available saves
Physics (opt-in, hand-rolled rigid-body world):
{"action":"physics", "enabled":true, "gravity":[0,-9.81,0], "iterations":8}
{"action":"body", "id":"<existing-object>",
"shape":"sphere|box|plane",
"mass":1.0, "static":false,
"restitution":0.3, "friction":0.5}
Sphere radius = object's max scale component.
Box half-extents = 0.5 * object's scale.
Plane is always static; offset defaults to object's y.
{"action":"impulse", "id":"<name>", "impulse":[x,y,z], "point":[x,y,z]?}
{"action":"velocity", "id":"<name>", "linear":[x,y,z], "angular":[x,y,z]}
{"action":"remove_body", "id":"<name>"}
Water (Gerstner waves + wave-surface buoyancy, orientation-aware for boxes):
{"action":"water", "enabled":true,
"level":0.0, "size":120,
"amplitude":0.3, "wavelength":8, "speed":1.0,
"shallow":[0.25,0.55,0.6], "deep":[0.02,0.12,0.22],
"density":1.0, "resolution":128}
Bodies bob on actual wave crests; boxes self-right based on corner
submergence; underwater fog activates when camera dips below water.
Weather (drives wave intensity + current + sky):
{"action":"weather", "wind_dir":[1,0,0], "wind_speed":2.5,
"storminess":0.6, "current_coef":0.8}
storminess 0..1: scales wave amplitude/choppiness, darkens sky, foam at crests.
wind_speed >0: surface current pushes floating bodies in wind_dir.
Sun/sky palette + HDR tuning:
{"action":"sun", "direction":[-0.3,0.35,-0.2], "color":[1.5,1.1,0.7],
"zenith":[0.15,0.35,0.65], "horizon":[0.95,0.65,0.45],
"ground":[0.12,0.1,0.08], "sky":[0.7,0.55,0.4],
"shadow_extent":80,
"exposure":0.9, "bloom_threshold":1.15,
"bloom_intensity":1.0, "fog_density":0.008}
Textures (diffuse + optional normal map — procedural names or .bmp/.tga files):
{"action":"create", ..., "texture":"grass", "normal_map":"noise",
"uv_scale":[2,2,1]}
{"action":"texture", "id":"<name>", "name":"wood", "normal_map":"noise"}
Built-in procedural textures:
white, black, checker, grid, stripes, noise
grass, dirt, rock, wood, brick, marble, concrete
Set vertex color near white so the texture color reads through.
"noise" also works well as a cheap normal map for micro-relief on any surface.
PBR material (Cook-Torrance GGX):
{"action":"create", ..., "metallic":1.0, "roughness":0.3,
"emissive":[4, 2, 0.5]}
{"action":"material", "id":"<name>", "metallic":0.9, "roughness":0.2,
"emissive":[0,0,0], "normal_map":"rock"}
metallic: 0 = dielectric (plastic/wood/stone), 1 = pure metal.
roughness: 0.05 = mirror, 1.0 = fully diffuse matte. 0.7 is default.
emissive: HDR RGB added after shading; drives bloom. Use for flames/lights.
A typical physics scene: create a ground plane object, register it as
a static plane body, then create some cubes/spheres above and register
them as dynamic bodies with mass>0. Enable physics. Watch them fall.
World coordinates: Y is up. A reasonable camera is at [10,8,10] looking at
[0,0,0]. Ground plane at y=0.
When a composition feels complete, save it with a descriptive name so it can
be reloaded later from the main menu."""
TOOLS = [
{
"name": "execute",
"description": (
"Run a list of engine commands against the 3D world. Commands are "
"executed in order. Use this for all world mutations — creation, "
"movement, deletion, camera, audio, engine settings. Prefer one "
"call with many commands over many calls with one."
),
"input_schema": {
"type": "object",
"properties": {
"commands": {
"type": "array",
"description": "List of engine command objects.",
"items": {"type": "object"},
}
},
"required": ["commands"],
},
},
{
"name": "query_world",
"description": (
"Read the current world state from the engine: object count, "
"named-object count, camera position and target, current FPS. "
"Use this to orient yourself before or after making changes."
),
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "wait",
"description": (
"Pause for a short time so animation or audio can play out "
"before you decide what to do next. Max 10 seconds."
),
"input_schema": {
"type": "object",
"properties": {
"seconds": {"type": "number", "description": "0.1 to 10"}
},
"required": ["seconds"],
},
},
]
class Engine:
def __init__(self, port: int) -> None:
self.base = f"http://localhost:{port}"
def _post(self, path: str, body: dict) -> dict:
data = json.dumps(body).encode()
req = urllib.request.Request(
self.base + path,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read().decode() or "{}")
def _get(self, path: str) -> dict:
with urllib.request.urlopen(self.base + path, timeout=10) as r:
return json.loads(r.read().decode() or "{}")
def execute(self, commands: list[dict]) -> dict:
if not commands:
return {"ok": True, "queued": 0}
if len(commands) == 1:
return self._post("/api", commands[0])
return self._post("/api", {"action": "batch", "commands": commands})
def query(self) -> dict:
return self._get("/api/status")
def ping(self) -> bool:
try:
self.query()
return True
except (urllib.error.URLError, ConnectionError):
return False
def run_tool(engine: Engine, name: str, args: dict) -> str:
if name == "execute":
result = engine.execute(args.get("commands", []))
return json.dumps(result)
if name == "query_world":
return json.dumps(engine.query())
if name == "wait":
seconds = max(0.1, min(10.0, float(args.get("seconds", 1.0))))
time.sleep(seconds)
return json.dumps({"waited": seconds})
return json.dumps({"error": f"unknown tool {name}"})
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
ap.add_argument("--max-turns", type=int, default=40)
ap.add_argument(
"--prompt",
default="Begin. This world is blank. Make something.",
help="Initial user message kicking God into action.",
)
args = ap.parse_args()
engine = Engine(args.port)
if not engine.ping():
print(
f"Cannot reach engine at {engine.base}. Start ModernEngine first.",
file=sys.stderr,
)
return 1
client = anthropic.Anthropic()
system = [
{
"type": "text",
"text": GOD_PROMPT + "\n\n" + COMMAND_SCHEMA_HINT,
"cache_control": {"type": "ephemeral"},
}
]
messages: list[dict] = [{"role": "user", "content": args.prompt}]
for turn in range(args.max_turns):
response = client.messages.create(
model=MODEL,
max_tokens=16000,
system=system,
tools=TOOLS,
thinking={"type": "adaptive"},
output_config={"effort": "high"},
messages=messages,
)
for block in response.content:
if block.type == "text" and block.text.strip():
print(f"\n[god] {block.text}")
elif block.type == "tool_use":
print(f"[tool] {block.name}({json.dumps(block.input)[:200]})")
if response.stop_reason == "end_turn":
print("\n[god has rested]")
return 0
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type != "tool_use":
continue
try:
result = run_tool(engine, block.name, block.input)
except Exception as e:
result = json.dumps({"error": str(e)})
tool_results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
}
)
if not tool_results:
# Model stopped without calling a tool and without end_turn.
break
messages.append({"role": "user", "content": tool_results})
print(f"\n[reached max-turns={args.max_turns}]")
return 0
if __name__ == "__main__":
sys.exit(main())