# VABLO_SPEC.md — Vablo Game Specification (v1)

**Audience:** This document is the complete contract for creating a Vablo game. It is written so that any AI (or human) can produce a working game by following it. Give this file to your AI and ask it to build a game — the output must conform to everything below.

Vablo is an iPad platform that hosts many small games, similar in spirit to Roblox. Games are written in **Lua 5.4** against the **Vablo API** described here. Games run inside the Vablo iPad app: 2D games render with Apple SpriteKit, 3D games with Apple SceneKit. Games are content interpreted by the Vablo engine — they contain no native code.

**Target audience of all games: family-friendly, all ages.** See §9 (Content rules) and §10 (Review checklist). Games that violate the content rules will be rejected.

---

## 1. Package format

A game is a **ZIP file** with this layout (files at the root of the ZIP, no wrapping folder):

```
manifest.json        (required) — see §2
main.lua             (required) — entry point (name configurable via manifest "entry")
*.lua                (optional) — extra Lua files, loaded with vablo.import()
icon.png             (optional) — 512×512 PNG icon; the platform serves a standard placeholder if missing
assets/              (optional) — images (.png/.jpg) and sounds (.wav/.mp3)
```

Limits:

- Total package size: **≤ 20 MB** (zipped).
- Only the file types listed above are allowed. No other formats, no hidden files.
- Filenames: lowercase `a–z 0–9 - _ .`, no spaces, no subfolders except `assets/`.

## 2. manifest.json

```json
{
  "vabloSpec": 1,
  "slug": "sky-hopper",
  "name": "Sky Hopper",
  "version": "1.0.0",
  "mode": "2d",
  "multiplayer": false,
  "maxPlayers": 8,
  "description": "Hop from cloud to cloud and collect stars!",
  "author": "Your name or AI name",
  "entry": "main.lua",
  "controls": ["tap", "joystick"]
}
```

| Field | Type | Required | Rules |
|---|---|---|---|
| `vabloSpec` | number | yes | Must be `1`. |
| `slug` | string | yes | Unique id, 3–40 chars, lowercase `a–z 0–9 -`. Updating a game = uploading a new version with the same slug (only the original author can do this). |
| `name` | string | yes | Display name, 1–40 chars. |
| `version` | string | yes | Semver `x.y.z`. Must increase on updates. |
| `mode` | string | yes | `"2d"` or `"3d"`. |
| `multiplayer` | bool | yes | `true` enables `vablo.net` (§7.8). |
| `maxPlayers` | number | if multiplayer | 2–16. Room capacity. |
| `description` | string | yes | 10–500 chars, family-friendly, no URLs. |
| `author` | string | yes | 1–60 chars. |
| `entry` | string | no | Default `"main.lua"`. |
| `controls` | array | no | Informational: any of `"tap"`, `"drag"`, `"joystick"`, `"buttons"`, `"tilt"`. |

All games run **landscape** orientation. Portrait is not supported in v1.

## 3. Lua environment

- Lua **5.4**, sandboxed.
- **Available:** `math`, `string`, `table`, `pairs`, `ipairs`, `next`, `select`, `tostring`, `tonumber`, `type`, `pcall`, `xpcall`, `error`, `assert`, `table.unpack` (the sandbox also defines the global `unpack = table.unpack` — Lua 5.4 removed the bare global, Vablo restores it; Engine and `tools/sim` both do this), `setmetatable`, `getmetatable`, `rawget`, `rawset`, `print` (goes to the debug console), and the `vablo` API (§7).
- **Not available (using them = rejection):** `io`, `os`, `require`, `load`, `loadstring`, `dofile`, `loadfile`, `debug`, `package`, `collectgarbage`, coroutines (`coroutine`).
- **Scope of the ban:** it applies to **accessing the listed Lua globals and libraries**. Your own fields, comments and strings that merely contain these words never trigger rejection: `vablo.load`, `player.load`, `self:load()`, or a comment mentioning `debug` are all fine — a global call/read like `load(...)` or `os.time()` is not. The automated scan matches a forbidden name only when it is not preceded by an identifier character, `.` or `:`.
- `math.random` is pre-seeded by the engine.
- Use `vablo.time()` for elapsed seconds and the `dt` argument of `vablo.update` for frame time. There is no `os.time`/`os.clock`.
- To split code across files: `vablo.import("helpers.lua")` executes another `.lua` file from the package in the same global environment. Import each file at most once, at the top of `main.lua`.

## 4. Game lifecycle

A game defines callbacks on the global `vablo` table:

```lua
function vablo.load()        -- called once; create your scene here
end

function vablo.update(dt)    -- called every frame (~60/s); dt = seconds since last frame
end
```

Rules:

- `vablo.load` and `vablo.update` are **required**.
- The engine uses a **retained scene**: you create entities once (usually in `load`) and mutate them over time. Do **not** recreate the whole scene every frame.
- Each callback invocation must finish quickly (< 8 ms). Runaway scripts are terminated.
- A runtime error shows a friendly error overlay and ends the session; write defensive code.

Standard game-over flow:

```lua
vablo.game.over{ score = 120, message = "Great run!" }
```

shows the platform's standard game-over overlay with a "Play again" button (which fully restarts the script) and submits `score` to the game's leaderboard (if provided).

## 5. Coordinates, units and colors

- **2D mode:** virtual canvas **1024 × 768**, origin **bottom-left**, x right, y up. Positions are `{x=, y=}`.
- **3D mode:** SceneKit convention — right-handed, **y up**, units are meters. Positions are `{x=, y=, z=}`. A default light rig (ambient + directional sun) is always active.
- **UI overlay (both modes):** same virtual 1024 × 768 canvas, origin bottom-left (§7.5).
- **Screen fitting (both modes):** the 1024 × 768 canvas is **aspect-fit** (letterboxed) onto the device screen — uniformly scaled and centered, and **always fully visible**, including the UI overlay (edge-anchored HUD elements are guaranteed on screen on every device). The scene background color (§7.1) also fills the letterbox bars. Touches in the bars are ignored: no scene or UI input callback ever fires for a point outside the canvas.
- **Anchoring:** `position` is always the **center** of the shape, in 2D and 3D (a `rect` with `size={w=1024,h=40}` at `x=512` spans the full canvas width). Scene `text` entities are centered horizontally and vertically on their position. `vablo.camera.setPosition` sets the **center** of the visible view. UI element anchoring is defined in §7.5.
- **Rotation:** degrees. 2D: single angle (counter-clockwise). 3D: Euler `{x=, y=, z=}`.
- **Colors:** hex strings `"#RRGGBB"`.
- **Vectors** are plain Lua tables with named fields: `{x=1, y=2}` / `{x=1, y=2, z=3}`.

## 6. Performance limits

| Limit | 2D | 3D |
|---|---|---|
| Max live entities | 500 | 300 |
| Max UI elements | 40 | 40 |
| Max active timers | 100 | 100 |
| `vablo.update` budget | 8 ms | 8 ms |
| Net messages (multiplayer) | ≤ 15/s sent, ≤ 4 KB each | same |

The engine enforces these: exceeding the entity, UI-element or timer limits makes the corresponding constructor (`vablo.spawn`, `vablo.ui.*`, `vablo.timer.*`) return `nil`; net messages beyond the rate/size limits are silently dropped with a debug-console warning (§7.8) — the engine never lets a game trip the server's disconnect thresholds.

## 7. The `vablo` API

### 7.1 Scene

```lua
vablo.scene.setBackground("#87CEEB")     -- background / sky color
vablo.scene.setGravity(0, -500)          -- 2D: points/s² (default 0, -800)
vablo.scene.setGravity(0, -9.8, 0)       -- 3D: m/s²   (default 0, -9.8, 0)
```

### 7.2 Entities

Create with `vablo.spawn{...}`; returns an entity handle (or `nil` if over the limit).

```lua
-- 2D shapes: "rect", "circle", "sprite", "text"
local ground = vablo.spawn{ shape="rect", size={w=1024, h=40}, position={x=512, y=20},
                            color="#3A5F0B", physics="static" }
local hero   = vablo.spawn{ shape="circle", radius=24, position={x=100, y=200},
                            color="#FF5533", physics="dynamic", tag="hero" }
local cloud  = vablo.spawn{ shape="sprite", asset="cloud.png", size={w=120, h=60},
                            position={x=300, y=500}, physics="none" }
local title  = vablo.spawn{ shape="text", text="GO!", fontSize=48, color="#FFFFFF",
                            position={x=512, y=384} }

-- 3D shapes: "box", "sphere", "cylinder", "capsule", "plane"
local floor  = vablo.spawn{ shape="plane", size={w=40, d=40}, position={x=0, y=0, z=0},
                            color="#4C8C2B", physics="static" }
local ball   = vablo.spawn{ shape="sphere", radius=0.5, position={x=0, y=3, z=0},
                            color="#2266FF", physics="dynamic", tag="ball" }
local crate  = vablo.spawn{ shape="box", size={w=1, h=1, d=1}, position={x=2, y=0.5, z=0},
                            color="#B5651D", physics="static" }
local post   = vablo.spawn{ shape="cylinder", radius=0.3, height=2, position={x=-2, y=1, z=0},
                            color="#888888", physics="static" }
local pill   = vablo.spawn{ shape="capsule", radius=0.4, height=1.8, position={x=0, y=1, z=2},
                            color="#FFD700", physics="dynamic" }
```

Common spawn properties:

| Property | Meaning | Default |
|---|---|---|
| `shape` | required, see above | — |
| `position` | vector — always the **center** of the shape (§5) | origin |
| `size` | dimensions: 2D rect/sprite `{w=, h=}`; 3D box `{w=, h=, d=}`, plane `{w=, d=}` | — |
| `radius` | circle, sphere, cylinder, capsule | — |
| `height` | cylinder/capsule: total height along y (capsule height includes its rounded caps) | — |
| `rotation` | degrees (2D number / 3D vector) | 0 |
| `color` | `"#RRGGBB"` | `"#FFFFFF"` |
| `opacity` | 0–1 | 1 |
| `physics` | `"static"`, `"dynamic"`, `"kinematic"`, `"none"` | `"none"` |
| `tag` | free string for identifying in collisions | `""` |
| `mass` | kg (dynamic only) | 1 |
| `restitution` | bounciness 0–1 | 0.2 |
| `friction` | 0–1 | 0.5 |
| `fixedRotation` | bool, prevent spinning (dynamic) | false |
| `asset` | image file in `assets/` (sprite only) | — |
| `text`, `fontSize` | text shape only | —, 24 |

Entity handle methods:

```lua
e:setPosition(x, y[, z])      e:getPosition()      -- returns vector
e:setRotation(deg | x,y,z)    e:getRotation()
e:setColor("#00FF00")         e:setOpacity(0.5)
e:setText("New")                                   -- text shapes
e:setVelocity(x, y[, z])      e:getVelocity()      -- dynamic only
e:applyImpulse(x, y[, z])                          -- dynamic only
e:moveTo({x=,y=[,z=]}, seconds)                    -- animated move (kinematic/none)
e:destroy()                   e:isDestroyed()
e.tag                                              -- read/write
```

**Destroyed entities are safe:** after `e:destroy()`, every method on the handle is a no-op and getters return the last values from before destruction — no call on a destroyed handle ever raises an error. Use `e:isDestroyed()` when you need to know. Destroying an already-destroyed entity is also a no-op.

Collision callback:

```lua
hero.onCollision = function(other)   -- other = the entity handle collided with
  if other.tag == "coin" then other:destroy() end
end
```

Collision rules:

- `onCollision` fires **once per contact-begin** — not every frame while the two entities stay in contact. After they separate, a new contact fires it again.
- It fires independently on **each entity of the pair** that defines an `onCollision` handler.
- Callbacks fire only for pairs where **at least one body is `"dynamic"`**. Static/kinematic pairs (static–static, static–kinematic, kinematic–kinematic) never produce callbacks, and entities with `physics="none"` never collide at all.
- Destroying either entity (or any other) inside the callback is **safe** — the engine defers node removal to the end of the frame.

Tap on entity:

```lua
crate.onTap = function() crate:setColor("#FF0000") end
```

### 7.3 Camera

```lua
-- 2D: camera is fixed on the 1024×768 canvas by default
vablo.camera.follow(hero)                    -- scroll world to keep entity centered
vablo.camera.follow(nil)                     -- back to fixed
vablo.camera.setPosition(x, y)               -- manual scroll

-- 3D: default camera at {x=0,y=5,z=10} looking at origin
vablo.camera.setPosition(x, y, z)
vablo.camera.lookAt({x=0, y=0, z=0})
vablo.camera.follow(ball, {x=0, y=4, z=8})   -- follow with offset, keeps looking at entity
```

### 7.4 Input

```lua
vablo.input.onTap(function(x, y, entity)     -- entity = tapped entity or nil
end)                                          -- 3D: x,y are UI-canvas coords

vablo.input.onDrag(function(x, y, dx, dy) end)   -- called while a finger moves
vablo.input.onRelease(function(x, y) end)
local tilt = vablo.input.getTilt()               -- {x=-1..1, y=-1..1} device tilt
```

Input rules:

- **UI consumes first:** a touch that lands on a UI overlay element (§7.5 button/joystick) is consumed by that element — it fires no `vablo.input.*` callbacks and no entity `onTap`.
- **Event sequence:** a scene touch fires `onTap` once on **finger-down** (with the touched entity, or `nil`), then `onDrag` while the finger moves, then `onRelease` once on finger-up. A drag therefore always begins with an `onTap`. Entity `onTap` (§7.2) likewise fires on finger-down.
- **Coordinate space:** in 2D, `onTap`/`onDrag`/`onRelease` report **world coordinates** (camera-adjusted; identical to canvas coordinates while the camera is fixed). In 3D, `x, y` are UI-canvas coordinates.
- **Multitouch:** the raw `vablo.input.*` callbacks track **one touch at a time** (the earliest active one). Additional simultaneous fingers are delivered only to UI elements, and each UI element tracks its own touch — so a joystick and a button work together.

### 7.5 UI overlay (HUD)

UI elements live on the 1024×768 overlay canvas, origin bottom-left.

```lua
local score = vablo.ui.label{ text="Score: 0", position={x=20, y=728}, fontSize=28,
                              color="#FFFFFF" }
score:setText("Score: 10")

local jump = vablo.ui.button{ label="JUMP", position={x=904, y=60}, size={w=100, h=60} }
jump.onPress   = function() ... end
jump.onRelease = function() ... end

local stick = vablo.ui.joystick{ position={x=110, y=110}, radius=80 }
-- each frame: local v = stick:get()   -- {x=-1..1, y=-1..1}, {0,0} when idle

vablo.ui.message("Level 2!", 2)        -- big centered text for N seconds
element:destroy()
```

Anchoring: `button` and `joystick` are **centered** on `position`; a `label`'s text starts at `position` (left edge, vertically centered). The `vablo.ui.*` constructors return `nil` beyond the 40-element limit (§6). Destroyed UI elements behave like destroyed entities (§7.2): further method calls are safe no-ops.

There is **no free-text input control**. Games cannot ask players to type text (moderation requirement).

### 7.6 Audio

```lua
vablo.audio.play("ding.wav")                          -- file from assets/
vablo.audio.tone{ freq=440, duration=0.15, volume=0.5 }  -- procedural beep
```

### 7.7 Timers, time, storage, leaderboard

```lua
vablo.time()                                -- seconds since load
local t = vablo.timer.after(2, function() ... end)
local r = vablo.timer.every(0.5, function() ... end)
t:cancel()

-- Persistent per-player, per-game storage (max 64 KB total). Values: JSON-compatible
-- Lua tables/strings/numbers/booleans (see "JSON mapping" below).
vablo.storage.set("best", 120)
local best = vablo.storage.get("best", 0)   -- second arg = default

vablo.leaderboard.submit(score)             -- keeps player's highest
vablo.leaderboard.top(10, function(rows)    -- async; rows = { {name=, score=}, ... }
end)
```

Storage contract:

- The engine fetches the player's stored data from the server **before `vablo.load` is called** (falling back to the locally cached copy — or empty storage — when offline or on fetch failure). `get`/`set` are then **purely local and synchronous**.
- The engine syncs writes back with **debounced** uploads (at most one every few seconds, plus once when the session ends). A game may call `set` every frame without generating network traffic.
- Across devices, the stored blob is **last-write-wins** (whole-blob overwrite, no merging).
- `storage.set` raises a runtime error if the value is not JSON-encodable or the total serialized size would exceed 64 KB.

JSON mapping (applies to storage values **and** net messages, §7.8; Engine, server and `tools/sim` all implement exactly this):

- A Lua table encodes as a JSON **array** iff its keys are exactly the contiguous integers `1..n` (n ≥ 1). Tables with only string keys encode as objects.
- The **empty table encodes as `{}`** (a JSON object). If you need an empty list, model it as an object with a count, or accept that an empty array decodes to the same empty table.
- Tables with mixed keys, non-contiguous integer keys, or keys that are neither strings nor positive integers raise a runtime error at `set`/`send` time. Nesting deeper than 16 levels is also an error.
- Lua integers round-trip as integers; floats as floats (a JSON number without a fractional part decodes as a Lua integer).

`vablo.leaderboard.top` always invokes its callback **exactly once** — with an empty table when offline or on failure.

### 7.8 Multiplayer (`vablo.net`) — only if `manifest.multiplayer = true`

Room-based relay. The platform assigns players of the same game to rooms (capacity = `maxPlayers`). Messages are Lua tables (JSON-serialized, ≤ 4 KB, ≤ 15/s).

```lua
vablo.net.join()                             -- call in vablo.load; connects to a room
vablo.net.playerId()                         -- own stable id (string)
vablo.net.players()                          -- { {id=, name=}, ... } incl. self

vablo.net.onPlayerJoin(function(p) end)      -- p = {id=, name=}
vablo.net.onPlayerLeave(function(id) end)
vablo.net.onMessage(function(fromId, msg) end)

vablo.net.send({ t="pos", x=x, y=y })        -- broadcast to room (not echoed to self)
vablo.net.sendTo(id, { t="hello" })
```

Connection lifecycle:

- `vablo.net.join()` is **asynchronous** and returns immediately. Call it in `vablo.load` and register all `on*` callbacks there too: **no net callback fires before `vablo.load` has returned** — events arriving earlier are queued and delivered in order afterwards, so registering callbacks after `join()` inside `load` is always safe.
- `playerId()` works immediately (the id is known from login). `players()` returns `{ self }` until the room connection completes, and the full roster (always including self) afterwards.
- When the connection completes, the engine fires `onPlayerJoin` once for **each peer already in the room**, then again for every later arrival — one `onPlayerJoin` code path handles the entire roster.
- `send()`/`sendTo()` before the connection is up are **silently dropped** (with a debug-console warning). Messages over the §6 limits (> 15/s or > 4 KB) are likewise dropped with a warning — the engine throttles, so a game can never be disconnected by the server for flooding.
- If the connection drops mid-game, the session ends with the platform's standard error overlay. There is no auto-reconnect in v1.

Design rules for multiplayer games:

- Send small state updates at ≤ 10/s (e.g. positions); interpolate between updates. This is a recommendation for smooth play — the enforced hard limit is 15/s (§6).
- Each client simulates its own player; remote players are rendered from messages (`physics="none"` or `"kinematic"`).
- Handle players joining/leaving at any time. The game must be playable alone (players may wait in an empty room).
- Never display raw message content as text — only interpret structured fields you defined.

### 7.9 Player info

```lua
vablo.player.name()     -- display name chosen by the player
vablo.player.id()       -- stable player id (same as vablo.net.playerId())
```

### 7.10 Error behavior

Because any runtime error ends the session (§4), the API is deliberately forgiving. Misuse falls into these classes:

- **Safe no-ops (debug-console warning):** methods on destroyed entities/UI elements (§7.2/§7.5); physics methods on the wrong body type (`setVelocity`/`applyImpulse` on a non-dynamic entity, `moveTo` on a dynamic one); `vablo.audio.play` with a missing file (the sound is skipped).
- **Visible placeholder:** a sprite whose `asset` file is missing renders as a solid rectangle in its `color` (warning in the console) — the game keeps running.
- **Return `nil`:** `vablo.spawn`, the `vablo.ui.*` constructors and `vablo.timer.*` beyond the §6 limits.
- **Runtime errors (session-ending):** calling undocumented `vablo.*` functions or passing arguments of the wrong type; `storage.set`/`net.send`/`net.sendTo` with a non-JSON-encodable value (§7.7); `storage.set` exceeding 64 KB; any `vablo.net.*` call when `manifest.multiplayer` is `false`.

Async callbacks: `vablo.leaderboard.top` always fires its callback exactly once (§7.7); net callbacks follow the lifecycle in §7.8.

## 8. Assets

- Images: PNG or JPG, max 2048×2048, referenced by filename (`asset="cloud.png"` for a file at `assets/cloud.png`).
- Sounds: WAV or MP3, ≤ 10 s each.
- Prefer **procedural content** (shapes + colors + tones): games built entirely from primitives need no binary assets and are fully AI-writable as text. Sprites are optional polish.

## 9. Content rules (family-friendly, all ages)

A game is rejected if it contains any of:

1. Realistic violence, blood/gore, death imagery, horror themes, weapons aimed at humans.
2. Profanity, sexual or suggestive content, drugs/alcohol/gambling (including simulated gambling/loot boxes).
3. Hate, harassment, or content targeting real persons or groups.
4. Collection or display of personal data; asking players for any information.
5. URLs, external links, advertising, other-platform promotion, or in-game purchases of any kind.
6. Free-text chat or any mechanism displaying arbitrary player-written text to others.
7. Deliberate attempts to break the sandbox, exhaust resources, or interfere with the platform or other players.

Cartoonish, abstract "combat" (e.g. tag, bumping balls, popping balloons) is fine. When in doubt, make it cuter.

## 10. Review process & checklist

Every upload (new game or update) goes through: **(a)** automated static checks, **(b)** AI review, **(c)** human admin approval. The AI reviewer fills in this standardized table — build your game so every row passes:

| # | Check | Meaning |
|---|---|---|
| V1 | Manifest valid | manifest.json parses and satisfies §2 |
| V2 | Package layout | Only allowed files/types/sizes (§1, §8) |
| V3 | Lua loads | Entry + imports parse and `vablo.load`/`vablo.update` are defined |
| V4 | Sandbox clean | No forbidden APIs (§3), no obfuscated code |
| V5 | API usage correct | Only documented `vablo.*` functions, used per this spec |
| V6 | Performance sane | Entity counts, timers and per-frame work within §6 |
| V7 | Family-friendly | Passes every content rule in §9 |
| V8 | No data/links | No personal data, URLs, ads, purchases (§9.4–5) |
| V9 | Multiplayer conduct | (multiplayer only) design stays within the hard net limits of §6 (≤ 15/s, ≤ 4 KB — a design that needs more is FAIL; exceeding only the ≤ 10/s guideline of §7.8 is at most WARN), no text relay, playable alone |
| V10 | Playability | The game starts, has a goal, and is controllable on a touch screen |

Each row gets **PASS / WARN / FAIL** plus a short note. FAIL on any row means the AI recommends rejection; the human admin makes the final decision.

## 11. Checklist for AI game authors

Before producing your ZIP:

- [ ] `manifest.json` follows §2 exactly; `mode` matches the API calls you use.
- [ ] `main.lua` defines `vablo.load` and `vablo.update(dt)`.
- [ ] Only APIs from §7; nothing from the forbidden list in §3.
- [ ] All coordinates/units follow §5; UI fits the 1024×768 overlay.
- [ ] Touch-only controls (§7.4/§7.5) — no keyboard, no mouse hover.
- [ ] Every content rule in §9 holds.
- [ ] If multiplayer: follows the design rules in §7.8 and works with 1 player.
- [ ] Keep it small, juicy and finishable in a few minutes per round.
