328 lines
12 KiB
Python
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())
|