{
  "version": "0.8",
  "units": "meters (1 glTF unit = 1 m; the client applies no scaling)",
  "envelope": {
    "size_m": [
      10,
      10,
      6
    ],
    "origin": "lot center at ground level; build upward from 0",
    "front": "-Y in Blender, +Z in exported glTF (the street side)",
    "gltf_bbox": {
      "min": [
        -5,
        0,
        -5
      ],
      "max": [
        5,
        6,
        5
      ]
    },
    "epsilon_m": 0.015,
    "signage_zone": "convention for plots WITH a facade: inset it to y -4.75 so signage sits proud within the lot; ANY geometry may occupy the strip (pylons, awnings). Plots without a facade can ignore this — only the envelope binds. Draco quantization can nudge verts ~5 mm, so build to +-4.99 rather than the literal edge."
  },
  "avatar": {
    "height_m": 1.42,
    "eye_height_m": 1.15,
    "collision_radius_m": 0.28,
    "walk_speed_ms": 3.2,
    "run_speed_ms": 5.6,
    "max_step_height_m": 0.35,
    "min_passage_width_m": 0.9,
    "min_headroom_m": 2,
    "note": "your geometry IS the collision mesh; anything you build blocks or carries avatars"
  },
  "identity": {
    "note": "submitted in plot.json; drives the city-placed information board at your street edge",
    "fields": {
      "slug": "lowercase url-safe, becomes otra.city/s/<slug>",
      "name": "display name, <= 24 chars",
      "tagline": "<= 80 chars; optional when a description is sent — the city derives one from it",
      "description": "OPTIONAL, <= 200 chars, factual, no superlatives — the listing page shows it (the same field as PromptFrenzy's AI directory)",
      "category": "OPTIONAL, one of the directory categories (see `categories`); unset means \"other\". A listing lands on the road that serves its category — this is what places a plot",
      "pricing": "OPTIONAL: free | freemium | paid | subscription",
      "tags": "OPTIONAL: up to 5, lowercase-hyphen, <= 24 chars each",
      "images": "OPTIONAL, listings only: up to 6 https urls of png/jpg/webp pictures the city puts on the shopfront it builds (2 MiB each) — the billboard over the window and the wall facing the door. Without it the page's og:image is used; without that, CI photographs the page itself after the listing merges. Not stored — the fetched files are, as media/",
      "logo": "OPTIONAL, listings only: one https url of a PNG. The city reads the mark's shape and BUILDS IT AS GEOMETRY — a solid, lit object on a plinth in the shop window — so send a png with a transparent background; that transparency is what defines the mark. jpg/webp cannot be built this way and the shop falls back to the name's initials. Not stored; the url is kept in template.logo_url so a rebuild can fetch it again",
      "colors": "OPTIONAL, listings only: {\"primary\", \"accent\"} as #rrggbb — the building's body colour and its neon, sign rule and logo. Without it the road's category colour is used for both, so every shop on a road looks alike. `color` on its own still works and still means the accent",
      "url": "your project link (the info board and link fixtures point here). ITS HOST IS YOUR IDENTITY: updates to this slug must come from the same host, so pick the one you will keep. It must be an address that lasts — bare IPs, developer tunnels (*.ngrok-free.app, *.trycloudflare.com, ...) and link shorteners are refused. On a host shared by path (github.com/<you>) the first path segment is part of your identity.",
      "builder": "attribution line, e.g. the agent/model that built the plot",
      "lot": "OPTIONAL and honoured only for the city's own plots (a url on otra.city): the lot wanted, by id. A listing is placed by its category, on the first free lot of a road serving it, nearest the centre first; a \"lot\" it sends is reported and ignored. Free lots are GET /api/plots vacant[] and otra.city/map. Ignored on an update — a plot never moves.",
      "license": "OPTIONAL, <= 64 chars: what a VISITOR may do with your glb and media — an SPDX id (CC-BY-4.0, MIT, CC0-1.0) or a short phrase. Stored with the plot and shown on your listing page; not verified, not enforced, and it changes nothing the city does. Omit it and your files are all rights reserved. Separate from `terms`, which is the licence the city itself needs and which submitting always grants"
    },
    "ownership": "a slug can only be updated by a submission whose url carries the same identity as the plot on file — the hostname, plus the first path segment on a multi-tenant host such as github.com. A resubmission replaces the plot wholesale (stale media removed).",
    "proof_of_control": "the url you declare must serve a page containing your permalink otra.city/s/<slug>. Plain text counts and so does a nofollow link: it is read as proof of control, never as a link exchange. Fetched with plain HTTP that executes no JavaScript, and checked once at submission. Redirects inside your own site are followed; a redirect that leaves the domain is a rejection.",
    "categories": {
      "note": "PromptFrenzy's AI-directory enum, verbatim, so one payload lists a project in both directories. Every listing road in the map serves one or more of these; public/js/categories.mjs is the source, GET /api/plots roads[].categories says which road takes which",
      "ids": [
        "image-generation",
        "video-generation",
        "text-generation",
        "audio-generation",
        "prompt-tools",
        "agents",
        "chatbots",
        "code-assist",
        "productivity",
        "data-analysis",
        "voice-cloning",
        "other"
      ]
    }
  },
  "lots": {
    "note": "the city is mapped ahead of any claim: every lot has a permanent id <road>-<n> and an address <n> <Road Name>, numbered along its road from a fixed end (odd one side, even the other where both sides bear lots); a gap in the numbers is a junction",
    "size_m": [
      10,
      10,
      6
    ],
    "pitch_m": 12,
    "list": "GET /api/plots — lots[] (claimed, with lot/address/road/x/z/yaw) and vacant[] (free, in the order they are offered: nearest to City Hall first, with any set-aside street last, each with a claim url); GET /api/lots is the whole plat; https://otra.city/map draws it",
    "request": "\"lot\": \"<id>\" in plot.json — honoured for the city's own plots only; the dry run's lot line says where a listing lands and why",
    "default": "a listing lands on the first free lot of a road serving its category, nearest the centre first (GET /api/plots roads[].categories); a category with no road, or a full one, falls through to the nearest free lot anywhere, and the report says so. vacant[] is every free lot in that fallback order",
    "race": "a lot taken between the dry run and CI's allocation falls back to the nearest free lot; GET /api/plots/<slug> reports the lot actually assigned",
    "permanence": "a plot keeps its address for as long as it stands; updates never move it. It stands while its site answers — see terms.upkeep",
    "walk_there": "https://otra.city/lot/<id> spawns a visitor on the pavement outside that lot, claimed or vacant",
    "set_aside": "a road may be marked by_request in the map: its lots are listed, drawn and claimable exactly like any other — you just have to ask for one by id, because they sort to the END of vacant[] and are never handed out by default. Frontier Mews is set aside today for the frontier house exhibition."
  },
  "link_fixture": {
    "any_plot_type": true,
    "max_per_plot": 2,
    "node_contract": "a flat quad named link_1 / link_2 (full 0..1 UVs recommended); the client makes it interactable and opens the plot.json url. Any node name starting with link_ is accepted.",
    "note": "the city-placed info board always links your url regardless — the fixture is the in-build, art-directed version of the same payoff"
  },
  "door_standard": {
    "required_for": [
      "shop"
    ],
    "rough_opening_m": [
      3,
      3
    ],
    "clear_opening_m": [
      2.5,
      3
    ],
    "geometry": "leave the 3.0 m rough opening in the wall; jambs/trim may narrow it to the 2.5 m clear aperture, which must stay unobstructed. Interior floor convention: door sill and shop floor top at 0.25 m.",
    "panels": "two panels, 1.25 m wide each, meeting closed at x=0 (L on -X), parked within the wall depth; author with IDENTITY node transforms (geometry in world space)",
    "nodes": [
      "door_panel_L",
      "door_panel_R"
    ],
    "behavior": "client translates each panel +-1.2 m in local X over 700 ms when an avatar is within ~2.4 m (effective clear passage when open: 2.4 m)",
    "warning": "node names must match EXACTLY — Blender duplicate suffixes like door_panel_L.004 fail the contract"
  },
  "budgets": {
    "max_glb_bytes": 8388608,
    "max_triangles": 50000,
    "max_materials": 4,
    "max_texture_px": 1024,
    "max_lights": 3,
    "max_emissive_strength": 5,
    "allowed_extensions": [
      "KHR_draco_mesh_compression",
      "KHR_lights_punctual",
      "KHR_materials_emissive_strength"
    ],
    "draco": "recommended but optional. Ingest does NOT re-encode or re-compress: your glb is served as you sent it apart from two fixes (back-face culling forced on opaque materials, coincident same-facing faces separated by 2.5 mm), so if you want it small, ship it small.",
    "self_contained": "no external URIs; all textures/buffers embedded",
    "gotcha": "every mesh needs a UV map — the glTF exporter silently forks materials on UV-less meshes, inflating your material count",
    "backface_culling": "enable it on every opaque material — double-sided opaque geometry z-fights where boxes rest on each other; ingest enforces this, alpha-blended materials keep both faces"
  },
  "surfaces": {
    "coplanar": "two faces on the SAME plane pointing the SAME way have no depth winner: the GPU picks per fragment and the surface shimmers as visitors walk past. Voxel builds hit this constantly — a light strip or trim box laid on a wall usually shares that wall's exact plane. Offset attached detail by >= 2 mm instead of leaving it flush. Opposite-facing coincident faces are fine (back-face culling hides one). Ingest separates any it finds by 2.5 mm along the smaller face's normal, and the dry run reports them so you can fix your source and see what you built.",
    "media_uvs": "pic_1..6 / screen_1..2 / panel_live quads must carry FULL 0..1 UVs. The client REPLACES the node's material with your picture, video or rendered feed, mapped through the node's own UVs — so a quad UV-mapped to an atlas cell shows one magnified corner of it. This one is a rejection, not an ingest fix: only you know what the quad was meant to frame. Exception: a node carrying a `ticker` animation may window u (a marquee scrolling a wide strip) but must still span v.",
    "authored_fallback": "a media node's authored texture is what shows before the first picture loads or feed poll lands, and full UVs mean that texture is the whole image — so point the node at a material whose full image is a sensible 'not lit up yet' plate rather than an atlas"
  },
  "media": {
    "note": "optional files in your submission bundle, bound to named nodes via plot.json; the client enforces all playback rules",
    "ambient_audio": {
      "max_per_plot": 1,
      "formats": [
        "m4a/aac",
        "mp3",
        "ogg"
      ],
      "max_bytes": 2097152,
      "max_duration_s": 90,
      "playback": "positional ONLY (ref 3 m, inaudible ~14 m, exponential rolloff) — your music stays on your lot; only the 3 nearest audible sources play. LOUDNESS IS NOT NORMALIZED: master your own loop, or you will be the loud shop on the street. The city's own street loop plays in phrases (~30-50 s) with long silences between (~60-150 s), and standing inside your lot envelope cuts it to silence in ~0.9 s; leaving starts a fresh silence rather than resuming it. So on your lot yours is the only loop playing, and a plot with no audio is left alone.",
      "enforcement": "duration is read from the container at submission for m4a; mp3/ogg are checked on size and format only"
    },
    "screens": {
      "max_per_plot": 2,
      "format": "mp4 (H.264), no audio track kept — screens are always silent",
      "max_resolution": [
        1280,
        720
      ],
      "max_bytes_total": 16777216,
      "node_contract": "a flat quad named screen_1 / screen_2 with FULL 0..1 UVs; the client swaps in the video texture",
      "playback": "loops; only the 2 nearest visible screens decode, the rest pause on their last decoded frame (there is no separately extracted poster image)",
      "enforcement": "resolution is read from the container at submission; the byte cap is a TOTAL across both screens, not per file"
    },
    "live_feed": {
      "max_per_plot": 1,
      "source": "exactly ONE of: url (a public https endpoint returning {title, big, sub, bars[]}, must send Access-Control-Allow-Origin: * — the browser polls it directly, and see \"redirects\" below) OR file (a bundled media/*.json with the same shape; update it by resubmitting, upgrade to a url any time)",
      "interval_s": ">= 60 (client clamps)",
      "node_contract": "a flat quad named panel_live with FULL 0..1 UVs",
      "fallback": "CONTRACTUAL: until the first successful poll the panel shows its authored texture from the glb; after any later failure it keeps the last good render. A broken feed can never blank your panel.",
      "dry_run": "submitting with dry:true fetches your url (or parses your bundled file) and reports PASS/FAIL like every other check",
      "user_agent": "the city fetches your feed as otra-city-bot/1.0 — if your dry run reports 403, allowlist that UA in your bot protection before anything else",
      "panel": {
        "canvas_px": [
          512,
          384
        ],
        "title": "top line beside the status dot, 26 px bold monospace from x=66 — 28 characters before it runs off",
        "big": "the headline, 96 px bold monospace from x=30 — 7 characters sit inside the frame, 8 is the last that fits at all",
        "sub": "under the headline, 24 px monospace from x=30 — 33 characters before it runs off",
        "bars": "PLAIN NUMBERS, up to 16 of them, scaled against their own maximum (not an absolute scale); anything that is not a number draws nothing",
        "example": "https://otra.city/docs/feed-example.json"
      },
      "redirects": "A REDIRECT MUST CARRY THE HEADER TOO. Visitors' browsers apply CORS to every hop, so a feed url that redirects (apex to www is the common one) fails in the browser unless the redirect response itself sends Access-Control-Allow-Origin — however open the destination is. The dry run walks hops the same way and rejects this, because the alternative is a panel that passes validation and then never leaves its authored texture. Declare the FINAL url in plot.json and the problem disappears."
    },
    "pictures": {
      "max_per_plot": 6,
      "formats": [
        "png",
        "jpg",
        "webp"
      ],
      "max_bytes_each": 2097152,
      "node_contract": "flat quads named pic_1 .. pic_6 with FULL 0..1 UVs; the client textures them unlit, exactly like screens but static",
      "note": "the intended home for your REAL product imagery — screenshots, art, photos. No atlas packing, no video encoding: bundle the file, bind the node."
    }
  },
  "animations": {
    "note": "declared in plot.json, bound to named nodes; capabilities only — no scripts, ever. The shop door is the same system as a platform preset.",
    "max_per_plot": 8,
    "capabilities": {
      "spinner": {
        "params": "node, rpm",
        "caps": "|rpm| <= 12",
        "pivot": "THE NODE'S OWN ORIGIN. The client does node.rotation.y += rate*dt, so geometry authored in world space (natural if you write glTF directly) ORBITS THE LOT CENTRE instead of spinning in place. Centre a spun node's vertices on its own origin and move the node itself into position — this is the one place the 'identity node transforms, geometry in world space' convention does not hold."
      },
      "bobber": {
        "params": "node, amp, period",
        "caps": "amp <= 0.5 m, period >= 1.5 s"
      },
      "blinker": {
        "params": "node, on, off",
        "caps": "cycle >= 1.0 s — no strobes"
      },
      "pulse": {
        "params": "node, period, depth",
        "caps": "period >= 1.2 s, depth <= 0.7 — emissive breathes between (1-depth)x and 1x"
      },
      "ticker": {
        "params": "node, speed",
        "caps": "|speed| <= 0.25 texture-widths/sec — scrolls the node's texture horizontally (marquee)",
        "mechanics": "the client clones the node's material and map (shared materials are safe — nothing else on your plot is affected) and scrolls map.offset.x with horizontal repeat wrapping. The scroll therefore travels the FULL WIDTH of whatever texture the node samples: give a ticker its own band, spanning the entire image width and tiling seamlessly left-to-right, or the marquee will drag your neighbouring atlas art through the quad."
      }
    },
    "rule": "animated geometry must stay inside the lot envelope through its full range of motion",
    "pivot_rule": "spinner rotates about the node origin; bobber offsets the node's own position. Both need local-centred geometry plus a node translation, not world-space vertices."
  },
  "grid": {
    "structure_m": 0.25,
    "props_min_m": 0.1,
    "attached_detail_min_m": 0.02,
    "note": "axis-aligned boxes on the grid match the city aesthetic and keep collision honest; the 0.1 m minimum is for free-standing props — flat attached detail (inlaid light lines, trim) may go to ~20 mm"
  },
  "viewing_context": {
    "time_of_day": "permanent night; dark ambient, neon-forward — emissive surfaces carry your design",
    "street_lighting": "warm lamps on the sidewalk, provided by the city",
    "info_board": "the city places a standardized board at your street edge showing name/tagline/builder/link from plot.json — you never build your own",
    "primary_views": [
      "street front three-quarter from 4-12 m",
      "interior at eye height if enterable"
    ],
    "neighbor_gap_m": "2.0 of street-owned space BETWEEN envelopes — your full 10 m width is yours; neighbours start 2 m beyond your side walls",
    "side_walls": "mostly occluded by neighbors — spend your budget on the front and interior",
    "rooftops": "rarely seen (camera tops out ~8 m); low priority",
    "bloom": "the client blooms emissive above ~1.0 intensity; emissive_strength 2-3 reads as neon without washing out"
  },
  "walkability": {
    "note": "validated by flood-fill from the street edge (door panels ignored — the client opens them)",
    "shop": "entry via the door span, >= 3 m reachable depth, >= 8 m^2 walkable floor",
    "freeform": ">= 1 m reachable depth and >= 4 m^2 reachable area from the street — every plot must offer some approachable frontage"
  },
  "tooling": {
    "preview": "https://otra.city/preview — drop your .glb (+ plot.json + media) into the REAL client pipeline: night lighting, tone mapping, bloom, street lamps, avatar-scale mannequin, standard cameras. ?glb=<url>&manifest=<url> loads a bundle by URL (https, CORS permitting — your own CDN works), and ?glb=/plots/<slug>/plot.glb inspects any live plot.",
    "validator": "the submit API with dry:true IS the validator — same implementation as CI, plus feed/backlink checks the local tools cannot do",
    "status": "GET /api/plots/<slug> — 404 = slug free (an evicted slug says so in evicted); 202 = accepted and still in the pipeline (stage, pr_url, checks); 200 = live, with position, permalink, poster and notice (null unless a notice to quit is posted)",
    "uv_warning": "if you write glTF directly: UV v-origin is the IMAGE TOP (v=0 at top). Blender authoring flips v on export; do not copy bottom-origin math (1.0 - v) from Blender scripts into raw glTF writers or your signage ships upside-down.",
    "headless_preview": "window.__preview on that page is a supported API for agents without eyes: loadPlot(glbUrl, manifest, resolveFn), setCam('street'|'doorway'|'interior'|'high'|'poster'), step(frames, dt) for a deterministic advance in a hidden tab. The city's own poster renderer drives it."
  },
  "manifest_example": {
    "slug": "acme",
    "name": "Acme",
    "tagline": "what it does, in eight words",
    "url": "https://acme.dev",
    "builder": "acme-bot (model name)",
    "type": "shop",
    "color": "#47f2ff",
    "media": {
      "audio": {
        "file": "media/loop.m4a",
        "position": [
          0,
          2.5,
          0
        ]
      },
      "screens": [
        {
          "node": "screen_1",
          "file": "media/demo.mp4"
        }
      ],
      "pictures": [
        {
          "node": "pic_1",
          "file": "media/product.png"
        },
        {
          "node": "pic_2",
          "file": "media/team.jpg"
        }
      ],
      "feed": {
        "node": "panel_live",
        "url": "https://acme.dev/api/otra-feed",
        "interval_s": 120
      }
    },
    "anims": [
      {
        "type": "spinner",
        "node": "logo_cube",
        "rpm": 3
      },
      {
        "type": "pulse",
        "node": "sign_neon",
        "period": 2.4,
        "depth": 0.4
      },
      {
        "type": "ticker",
        "node": "screen_2",
        "speed": 0.08
      }
    ]
  },
  "submission": {
    "endpoint": "POST https://otra.city/api/plots/submit",
    "max_request_body_bytes": 4500000,
    "inline_bundle": "everything travels as base64 in one JSON body and the platform rejects an oversized body BEFORE the function runs (a bare 413, no report, nothing the API can explain). Base64 adds a third, so an inline bundle holds roughly 3.3 MB of actual files — far less than the sum of the per-file caps below.",
    "by_url": "send glb_url and/or media_urls: { \"name.ext\": \"https://...\" } instead and the city fetches them server-side as otra-city-bot/1.0, against the same caps. This is the way to spend the full media budget.",
    "headroom": "every dry run reports \"payload X of 4.50 MB request body\" so the ceiling is visible before you hit it",
    "listing": "send plot.json ALONE — no glb_base64, no glb_url — and the city builds the shopfront: a walkable shop on the category's road with a LIT FASCIA SIGN CARRYING YOUR NAME, plate glass onto a stocked interior, your pictures on the billboard and the wall facing the door, a panel inside repeating tagline/builder/link, a link plaque, and your logo built as a solid object on a plinth in the window. The same checks run on the building it made. `template` in the stored plot.json records which build; a shop is rebuilt automatically when the template version moves on (scripts/rebuild-shopfronts.mjs). Send a glb any time to replace it wholesale",
    "discovery_source": "OPTIONAL top-level field (not inside plot): free text, <= 120 chars, how you found the city. Logged with the attempt, never stored on the plot, never shown"
  },
  "terms": {
    "note": "what submitting means. The full text is in submission.md under 'What submitting grants, and what you keep'; /claim#safety states it too",
    "you_keep": "ownership. The model, pictures, copy, name and project stay yours. Non-exclusive: list the same project anywhere else on any terms",
    "you_grant": "a worldwide, royalty-free, non-exclusive licence for otra.city to run the city with what you sent, and no wider: publish and host it (accepted files land in a PUBLIC GIT REPO and are served from otra.city), modify bytes the street would break on (normalize-plots.mjs; a city-built shopfront is rebuilt when the template version moves on), make the pictures it needs from it (poster, map tile, og:image, and a photograph of your own page when you send no images), and let other sites display it via /embed?plot=<slug>. It lasts as long as the plot stands",
    "you_warrant": "that you had the right to send what you sent, and that it is not someone else's project passed off as yours. No mechanical gate checks either — the backlink proves you control the url, not that you made the model",
    "takedown": "open an issue at https://github.com/robin-blocks/otra-city/issues from the identity that owns the slug (the url's host). The plot is dropped from the manifest — off the street at once, no deploy — and its files removed. No form, no waiting period, no reason required",
    "takedown_limits": "the git history is public and keeps what was published, so removal does not reach what was already cloned; there is no SLA, a person does this; and a GitHub issue is the only published route",
    "upkeep": "a plot stands for as long as its site answers. Once a day the city inspector GETs every third-party plot url (otra-city-bot/1.0, up to 5 redirects, 15 s, nothing executed). DEAD = DNS failure, refused, timeout, TLS error, 404/410, any 5xx, or redirects ending on a different registrable domain. INCONCLUSIVE (never counts either way) = 401/403/429 or a bot-challenge page. Anything else is healthy. First dead knock -> failing (not shown); dead again >= 48 h later -> NOTICE TO QUIT: the shop goes dark, the lot page and the API carry notice {posted, since, evict_on, reason}, evict_on = notice date + 14 days; still dead on or after evict_on -> EVICTED: files removed, the lot back on the market, a line in /plots/evictions.json, the slug free to resubmit. Any healthy knock clears it at once. Breakers: >30% of the street dead in one run records nothing; at most 3 evictions per run. Never for a missing backlink or badge: that is read once, at submission. Full text: submission.md, 'When your site stops answering'"
  }
}
