# Wherigo Lua API Reference

## Overview

Wherigo cartridges are written in Lua 5.1. The runtime provides a `Wherigo` module
that is loaded via `require "Wherigo"`. All game objects, events, and actions are
accessed through this module.

**Important constraints:**
- Only `require "Wherigo"` is allowed. Do NOT use `require "table"`, `require "math"`, etc.
- **NEVER set `Media = nil` or `Icon = nil`** on any object. This crashes the iOS Wherigo app.
  Omit these properties entirely — the Wherigo runtime handles its own defaults.
  Only set Media/Icon when assigning an actual ZMedia object.
- **No apostrophes in Lua comments** — `'` in `-- comment` lines crashes the
  Groundspeak compiler. Apostrophes in strings are perfectly safe — use
  contractions ("don't", "he's", "it's") freely in game text.
- Standard Lua functions (`table.insert`, `math.floor`, `string.format`, etc.) are
  available as globals — you just can't `require` their modules.

---

## Sandbox: Available and Unavailable Libraries

The Wherigo runtime and the wheriflo simulator both sandbox the Lua environment.
Some standard libraries are available as globals, others are removed entirely.

| Library | Available | Notes |
|---------|-----------|-------|
| `math` | **Yes** | `math.floor`, `math.random`, `math.abs`, etc. all work |
| `table` | **Yes** | `table.insert`, `table.remove`, `table.sort`, etc. |
| `string` | **Yes** | `string.format`, `string.sub`, `string.find`, etc. |
| `tostring`, `tonumber`, `type` | **Yes** | Global utility functions |
| `pairs`, `ipairs`, `next` | **Yes** | Iteration functions |
| `pcall`, `xpcall` | **Yes** | Error handling |
| `os` | **No** | Removed. `os.time()`, `os.clock()`, `os.date()` are not available |
| `io` | **No** | Removed. No file I/O |
| `debug` | **No** | Removed. No debug introspection |
| `loadfile`, `dofile` | **No** | Removed. Cannot load external files |
| `require` | **Limited** | Only `require "Wherigo"` is allowed |

**Common pitfall:** `math.randomseed(os.time())` will crash because `os` is nil.
Use a coordinate-based seed instead:
```lua
math.randomseed(math.floor(cart.StartingLocation.latitude * 100000))
```

**Note:** Real Wherigo player apps (iOS, Android) may have slightly different
sandbox rules — some allow `os.time()`. If your cartridge needs timers, use
`Wherigo.ZTimer` objects instead of `os`-based timing.

**Note:** Lua comments (`-- ...`) and blank lines are stripped during bytecode
compilation. They have zero impact on compiled cartridge size. Don't waste time
removing comments if you're trying to reduce size — focus on string literals
and code deduplication instead. See the design guide's "Managing Cartridge Size"
section.

---

## Line Breaks on iOS

The iOS Wherigo app only renders line breaks from `[[ ]]` long strings. The
Groundspeak compiler processes these strings and adds internal encoding that
the iOS app recognizes. Plain `\n` in quoted strings does **not** render as
a line break on iOS (though it works on Android and the wheriflo web player).

### Static text: use `[[ ]]` long strings

```lua
Wherigo.MessageBox{
    Text = [[Welcome to the adventure!

This is a new paragraph.
This is a new line.]]
}
```

### Dynamic text: concatenate `[[ ]]` fragments

You can build dynamic strings with iOS line breaks by concatenating `[[ ]]`
fragments — but beware of Lua's first-newline stripping rule.

**Lua strips the first newline immediately after `[[`.** This is per the Lua
spec and is silent — no error, the string just has one fewer newline. When
concatenating, you must add an extra newline to compensate:

```lua
-- BROKEN: first newline after [[ is stripped — no line break!
msg = [[Line one.]] .. [[
Line two.]]
-- Result: "Line one.Line two."

-- FIXED: double newline gives one line break
msg = [[Line one.]] .. [[

Line two.]]
-- Result: "Line one.\nLine two."

-- For a blank line gap, use triple newline:
msg = [[Line one.]] .. [[


Line two.]]
-- Result: "Line one.\n\nLine two."
```

### Dynamic text with variables

For text that includes Lua variables (scores, names, counts), concatenate
`[[ ]]` fragments around the variables:

```lua
msg = [[Score: ]] .. score .. [[

Rating: ]] .. rating .. [[

Path: ]] .. path
```

Each `[[ ]]` fragment after `..` needs a double newline at the start to get
a single line break on iOS (because the first newline is stripped).

### Runtime line-break constant: use `string.char(10)`, not `[[ ]]`

A common idiom for a reusable newline (e.g. to `table.concat` a list of lines)
is a one-line long string:

```lua
NL = [[
]]                          -- BROKEN: this is an EMPTY string on device
local text = table.concat(lines, NL)
```

Because Lua strips the first newline immediately after `[[`, a long string
whose only content is that newline compiles to `""` in the **local wheriflo
build** (conformant Lua 5.1). The Groundspeak compiler instead encodes `[[ ]]`
newlines as a `<BR>` tag, but field testing found this `[[<newline>]]` idiom
does **not** reliably render a line break on iOS. Either way it is fragile —
`table.concat(lines, NL)` and every `x .. NL .. y` can produce **no line breaks
on device**, even though the wheriflo simulator may show them. Use a real
newline byte instead:

```lua
NL = string.char(10)        -- a real newline byte
local text = table.concat(lines, NL)
```

`wheriflo lint` warns about the `NL = [[<newline>]]` idiom.

### Fallback: multiple MessageBox calls

For complex dynamic text where the concatenation pattern is awkward, use
separate MessageBox calls:

```lua
Wherigo.MessageBox{
    Text = [[Your final score:]],
    Callback = function()
        Wherigo.MessageBox{ Text = "Score: " .. score .. " / " .. total }
    end
}
```

### Platform support

| Pattern | iOS | Android | Web player |
|---------|-----|---------|------------|
| `[[ ]]` long strings | Line breaks | Line breaks | Line breaks |
| `"\n"` in quoted strings | No breaks | Line breaks | Line breaks |
| `"<BR>\n"` manually added | Shows `<BR>` as text | Shows `<BR>` as text | Shows `<BR>` as text |

---

## Module Setup

```lua
require "Wherigo"
ZonePoint = Wherigo.ZonePoint
Distance = Wherigo.Distance
Player = Wherigo.Player
```

---

## Player

```lua
Player = Wherigo.Player
```

The `Player` object is a global singleton representing the person playing the cartridge.

### Properties
| Property | Type | Description |
|----------|------|-------------|
| `Name` | string | Player's display name |
| `ObjectLocation` | ZonePoint | Player's current GPS position |
| `CompletionCode` | string | Unique code generated at compile time. Display this to the player when they complete the cartridge so they can log their find on wherigo.com. |

### Completion Code

The completion code is a per-player, per-download string that proves the player
finished the cartridge. It is customized by wherigo.com when a signed-in player
downloads the .gwc file. **You must display it to the player** — otherwise they
have no way to log their completion.

```lua
cart.Complete = true
local code = Player.CompletionCode or "(code unavailable in simulator)"
local shortCode = string.sub(code, 1, 15)
Wherigo.MessageBox{
    Text = [[Your unlock code: ]] .. code .. [[

(If rejected, try the first 15 characters: ]] .. shortCode .. [[)]],
}
```

**Key points:**
- `cart.Complete = true` must be set for the code to be generated.
- Some Wherigo players only accept the first 15 characters — show both.
- During wheriflo play-testing, the code is `TEST-CODE-12345`.
- In a locally built .gwc, the code may be a placeholder. For a real test,
  upload the .gwz to wherigo.com and re-download.

---

## Cartridge

```lua
cart = Wherigo.ZCartridge()
```

### Properties
| Property | Type | Description |
|----------|------|-------------|
| `Name` | string | Cartridge display name |
| `Description` | string | Long description (use `[[ ]]` for multiline) |
| `Id` | string | GUID for the cartridge |
| `StartingLocation` | ZonePoint | GPS coordinates of the start |
| `StartingLocationDescription` | string | Text description of the start area |
| `Version` | string | Version string, e.g. `"1.0"` |
| `Author` | string | Author name |
| `Company` | string | Organization name |
| `Complete` | boolean | Set to `true` to mark cartridge as completed |
| `Activity` | string | Cartridge type: `"Geocache"` (tied to a physical cache listing), `"WherigoGame"` (standalone game), or `"Puzzle"` (play-anywhere). Default: `"Geocache"` |

### Callbacks
| Callback | Signature | Description |
|----------|-----------|-------------|
| `OnStart` | `function()` | Called when the player starts the cartridge |

### Example
```lua
cart = Wherigo.ZCartridge()
cart.Id = "a1b2c3d4-..."
cart.Name = "My Adventure"
cart.Description = [[A walking mystery through downtown.]]
cart.StartingLocation = ZonePoint(47.6019, -122.3321, 0)
cart.Version = "1.0"
cart.Author = "Jane Smith"
cart.Complete = false

cart.OnStart = function()
    -- Game begins here
    zone1.Active = true
    zone1.Visible = true
    Wherigo.MessageBox{ Text = "Welcome to the adventure!" }
end
```

---

## Zone

```lua
zone = Wherigo.Zone(cart)
```

### Properties
| Property | Type | Description |
|----------|------|-------------|
| `Name` | string | Display name |
| `Description` | string | Shown when entering the zone |
| `Id` | string | Unique identifier |
| `Visible` | boolean | Whether the zone appears on the map |
| `Active` | boolean | Whether the zone triggers OnEnter/OnExit |
| `OriginalPoint` | ZonePoint | Center point of the zone |
| `Points` | table of ZonePoint | Polygon boundary (4+ points recommended) |
| `DistanceRange` | Distance | Visibility range (`-1` = always) |
| `ProximityRange` | Distance | Trigger radius |
| `ShowObjects` | string | `"OnEnter"`, `"Always"`, or `"Never"` |
| `Media` | ZMedia | Image/audio for the zone (do NOT set to nil) |
| `Icon` | ZMedia | Map icon (do NOT set to nil) |

### Callbacks
| Callback | Signature | Description |
|----------|-----------|-------------|
| `OnEnter` | `function()` | Player enters the zone boundary |
| `OnExit` | `function()` | Player leaves the zone boundary |

### Example
```lua
zone1 = Wherigo.Zone(cart)
zone1.Id = "zone-001"
zone1.Name = "The Park Entrance"
zone1.Description = "A wrought-iron gate marks the entry."
zone1.Visible = false
zone1.Active = false
zone1.OriginalPoint = ZonePoint(47.6019, -122.3321, 0)
zone1.Points = {
    ZonePoint(47.6021, -122.3323, 0),
    ZonePoint(47.6021, -122.3319, 0),
    ZonePoint(47.6017, -122.3319, 0),
    ZonePoint(47.6017, -122.3323, 0),
}
zone1.DistanceRange = Distance(-1, "feet")
zone1.ProximityRange = Distance(60, "meters")
zone1.ShowObjects = "OnEnter"

zone1.OnEnter = function()
    Wherigo.MessageBox{ Text = "You arrive at the park entrance." }
end
```

> **iOS NOTE:** The iOS Wherigo app does NOT fire `OnEnter` when a zone becomes active while the player is already inside it. If your cartridge dynamically activates zones (e.g., after a game event), use the `activateZoneOrEnterIfInside` helper from the design guide to ensure iOS compatibility.

---

## Character

```lua
char = Wherigo.ZCharacter(cart)
```

### Properties
| Property | Type | Description |
|----------|------|-------------|
| `Name` | string | Character display name |
| `Description` | string | Character description |
| `Id` | string | Unique identifier |
| `Visible` | boolean | Whether the character appears in "You See" |
| `Gender` | string | `"Male"`, `"Female"`, or `"NA"` |
| `Media` | ZMedia | Character image (do NOT set to nil) |
| `ObjectLocation` | ZonePoint or nil | Where the character is located |

### Methods
| Method | Description |
|--------|-------------|
| `MoveTo(target)` | Move the character to a zone or container. Use `char:MoveTo(zone1)` to place in a zone. |

### Example
```lua
guide = Wherigo.ZCharacter(cart)
guide.Id = "char-guide"
guide.Name = "The Guide"
guide.Description = "An elderly man who knows the area well."
guide.Visible = false
guide.Media = nil
guide.Icon = nil
```

---

## Item

```lua
item = Wherigo.ZItem(cart)
```

### Properties
| Property | Type | Description |
|----------|------|-------------|
| `Name` | string | Item display name |
| `Description` | string | Item description |
| `Id` | string | Unique identifier |
| `Visible` | boolean | Whether the item appears in inventory |
| `Container` | object or nil | What contains this item |
| `Commands` | table | List of ZCommand objects (action buttons) |
| `Media` | ZMedia | Item image (do NOT set to nil) |
| `ObjectLocation` | ZonePoint or nil | Where the item is located |

### Methods
| Method | Description |
|--------|-------------|
| `MoveTo(target)` | Move the item to a container. Use `item:MoveTo(Player)` to add to inventory, `item:MoveTo(zone1)` to place in a zone. |

**Note:** Do NOT use `Player:MoveObject(item, Player)` — this method does not exist. Always use `item:MoveTo(target)` instead.

### Example
```lua
key = Wherigo.ZItem(cart)
key.Id = "item-key"
key.Name = "Old Key"
key.Description = "A rusted key that might fit an old lock."
key.Visible = false
key.Media = nil
key.Icon = nil
```

---

## Task

```lua
task = Wherigo.ZTask(cart)
```

### Properties
| Property | Type | Description |
|----------|------|-------------|
| `Name` | string | Task display name |
| `Description` | string | What the player needs to do |
| `Id` | string | Unique identifier |
| `Visible` | boolean | Whether it appears in the task list |
| `Active` | boolean | Whether the task is currently active |
| `Complete` | boolean | Whether the task has been completed |
| `CorrectState` | string | `"None"`, `"Correct"`, or `"NotCorrect"` |
| `Media` | ZMedia | Task image (do NOT set to nil) |

### Example
```lua
task1 = Wherigo.ZTask(cart)
task1.Id = "task-001"
task1.Name = "Find the hidden entrance"
task1.Description = "Search the north wall for a concealed passage."
task1.Visible = false
task1.Active = false
task1.Complete = false
```

> **iOS NOTE:** The iOS Tasks page sorts tasks **alphabetically by `Name`**, not
> in declaration order. If order matters to the player, prefix names with numbers
> (`"1. ..."`, `"2. ..."`). `wheriflo lint` warns when tasks are not declared in
> alphabetical order.

---

## Command (ZCommand)

Commands add action buttons to inventory items. When the player taps a command button on an item's detail screen, the handler fires.

### Canonical Pattern (iOS-compatible)

Use **named keys** in `item.Commands` and define the handler as a method on the **item**, not the command:

```lua
item.Commands = {
    cmdOpen = Wherigo.ZCommand{
        Text = "Open the Chest",
        CmdWith = false,
        EmptyTargetListText = "Nothing available",
    }
}
item.Commands.cmdOpen.Custom = true
item.Commands.cmdOpen.Id = "cmd-open"
item.Commands.cmdOpen.WorksWithAll = true

function item:OncmdOpen(target)
    -- This fires when the player taps the button
    Wherigo.MessageBox{Text = "The chest creaks open!"}
end
```

**Key points:**
- The command key name (e.g. `cmdOpen`) determines the handler method name: `item:On` + key → `item:OncmdOpen(target)`
- `CmdWith = false` for self-contained actions (no target selection)
- `Custom = true` and `WorksWithAll = true` are required for the iOS app to display the button correctly
- `EmptyTargetListText` (note: not `EmptyTargetText`) is the message shown when no valid target exists

### Properties
| Property | Type | Description |
|----------|------|-------------|
| `Id` | string | Unique identifier |
| `Text` | string | Button label shown to the player |
| `CmdWith` | boolean/string | `false` for self-contained actions |
| `Custom` | boolean | Must be `true` for custom commands |
| `WorksWithAll` | boolean | Must be `true` for the command to appear |
| `EmptyTargetListText` | string | Message when no valid target exists |

> **WARNING:** Do NOT set `Enabled = true` (or any value) on a ZCommand declared as a standalone variable. This causes the Groundspeak compiler to return HTTP 500. When using the canonical named-command pattern with `Enabled` inside the constructor table, it is safe.

> **WARNING:** `cmd.OnClick` is **simulator-only** — the iOS Wherigo app ignores it and shows a generic "Start" button instead. Always use the canonical `item:OncmdName(target)` pattern for iOS compatibility. The wheriflo simulator supports both patterns, but `OnClick` will produce a lint warning.

### Example (multiple commands)

```lua
key = Wherigo.ZItem(cart)
key.Name = "Enchanted Key"
key.Commands = {
    cmdUse = Wherigo.ZCommand{Text = "Use the Key", CmdWith = false},
    cmdExamine = Wherigo.ZCommand{Text = "Examine Closely", CmdWith = false},
}
key.Commands.cmdUse.Custom = true
key.Commands.cmdUse.WorksWithAll = true
key.Commands.cmdExamine.Custom = true
key.Commands.cmdExamine.WorksWithAll = true

function key:OncmdUse(target)
    key.Visible = false
    Wherigo.MessageBox{Text = "The lock clicks open."}
end

function key:OncmdExamine(target)
    Wherigo.MessageBox{Text = "Strange markings glow faintly."}
end
```

---

## Media

```lua
img = Wherigo.ZMedia(cart)
```

Media objects wrap image and audio files for use in cartridges.

### Properties
| Property | Type | Description |
|----------|------|-------------|
| `Id` | string | Unique identifier |
| `AltText` | string | Description shown when image can't display |
| `Resources` | table | List of file resources (see below) |

### Resources Table

Each entry in `Resources` describes a file:

```lua
img = Wherigo.ZMedia(cart)
img.Id = "media-splash"
img.AltText = "A mysterious old map"
img.Resources = {
    { Type = "jpg", Filename = "splash.jpg", Directives = "" }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `Type` | string | File format: `"jpg"`, `"png"`, `"gif"`, `"bmp"`, `"mp3"`, `"wav"`, `"ogg"` |
| `Filename` | string | Filename (must be in the same directory as the .lua file) |
| `Directives` | string | Optional. Use `"noresize"` to prevent Garmin auto-resize. |

### Using Media

Assign a ZMedia object to any `Media` or `Icon` property:

```lua
zone1.Media = img           -- zone detail image
char1.Media = imgPortrait   -- character portrait
item1.Media = imgMap         -- inventory item

Wherigo.MessageBox{
    Text = "You found a clue!",
    Media = imgClue,         -- image shown in message
}
```

**Important:**
- **Declare ZMedia objects AFTER the ZCartridge** — the cartridge variable must exist before creating media. The Groundspeak compiler will silently ignore media declared before the cartridge.
- **NEVER set `Media = nil` or `Icon = nil`** — this crashes the iOS app. Omit the property entirely if no media.
- Place image files in the same directory as your `.lua` file.
- The build command auto-discovers media files from `ZMedia` declarations and embeds them in the GWC/GWZ.
- Supported formats: JPG (best for photos), PNG (best for graphics), GIF, BMP.
- **Cartridge icons:** Use PNG at 256x256 with bold, simple shapes. The iOS app displays icons at ~60px — avoid fine detail and small text. See the design guide's "Images & Media" section.
- **Content images:** 640x480 is a good default. PNG survives Groundspeak reprocessing better than JPG.

---

## Input

```lua
input = Wherigo.ZInput(cart)
```

### Properties
| Property | Type | Description |
|----------|------|-------------|
| `Text` | string | Prompt text shown to the player |
| `InputType` | string | `"MultipleChoice"` or `"Text"` |
| `Choices` | table of strings | Answer options (for MultipleChoice) |
| `Id` | string | Unique identifier |
| `Visible` | boolean | (usually false — inputs are triggered, not browsed) |

> **iOS/Android NOTE (multiple choice):** Real players render choices as a
> **picker** (iOS scroll-wheel with a Done button; Android dropdown Spinner), not
> tappable buttons. Write prompts with **"select"/"choose", never "tap"/"click"**,
> and keep labels short and **single-spaced** — 2+ consecutive spaces in a label
> render as a literal `&nbsp;` on the iOS picker. `wheriflo lint` warns about both.

### Callbacks
| Callback | Signature | Description |
|----------|-----------|-------------|
| `OnGetInput` | `function(input, answer)` | Called when the player selects a choice |

### Example
```lua
inputQ1 = Wherigo.ZInput(cart)
inputQ1.Id = "input-q1"
inputQ1.InputType = "MultipleChoice"
inputQ1.Text = "What color is the door?"
inputQ1.Choices = {"Red", "Blue", "Green", "Yellow"}

inputQ1.OnGetInput = function(input, answer)
    if answer == "Blue" then
        Wherigo.MessageBox{ Text = "Correct! The door swings open." }
    else
        Wherigo.MessageBox{ Text = "That doesn't seem right." }
    end
end
```

To trigger an input:
```lua
Wherigo.GetInput(inputQ1)
```

> **iOS NOTE (free-text `InputType = "Text"`):** Three rules, all verified by iOS
> device testing:
> 1. **Validate inline.** Compare the `answer` inside the input's own
>    `OnGetInput` handler. Do NOT forward `answer` through a helper function or
>    an extra callback layer — that has silently rejected correct answers on
>    device. Give each prompt its own `Wherigo.ZInput`.
> 2. **Never call a string function on `answer`.** `string.upper(answer)`,
>    `string.find(answer, ...)`, `string.gsub(answer, ...)` have all misbehaved
>    on device (one normalizer collapsed every input to the same value, so the
>    gate accepted anything). String ops on your *own* constants are fine.
> 3. **For exact-match tokens, enumerate `==`.** Build the accepted variants from
>    constants and compare with bare `==`. Do not use a `string.find` substring
>    fallback for exact tokens — it accepts trailing junk (`7596` would accept
>    `75960`).
> ```lua
> local PASSWORD = "swordfish"
> local VARIANTS = {
>     PASSWORD, string.upper(PASSWORD), string.lower(PASSWORD),
>     -- iOS auto-capitalizes the first letter of text fields:
>     string.upper(string.sub(PASSWORD,1,1)) .. string.lower(string.sub(PASSWORD,2)),
> }
> inputPass.OnGetInput = function(input, answer)
>     for i = 1, #VARIANTS do
>         if answer == VARIANTS[i] then unlock(); return end
>     end
>     Wherigo.GetInput(inputPass)  -- retry
> end
> ```
> See the design guide's "Free-text answers on iOS" for the full pattern.

---

## Actions

### MessageBox

```lua
Wherigo.MessageBox{
    Text = "Hello, adventurer!",
    Media = nil,
    Callback = function(action)
        -- Called when the player dismisses the message
    end,
}
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `Text` | string | The message text |
| `Media` | ZMedia or nil | Optional image/audio (omit if unused) |
| `Callback` | function or nil | Called on dismiss (receives `"Button1"`) |

> **iOS NOTE:** On iOS, raising a `MessageBox`/`GetInput` while another is on
> screen **replaces** it — and the replaced box's `Callback` **never fires**. The
> simulator queues them instead, so concurrent-modal flows work in the sim and
> dead-end on device. Never let two flows raise modals at once; in particular,
> don't activate a zone the player may be standing in (synchronous `OnEnter`)
> while an intro message is mid-flight. See the design guide's "iOS Sim-Parity
> Gotchas".

### GetInput

```lua
Wherigo.GetInput(inputObj)
```

Shows the input's prompt and choices. When the player selects an option,
`inputObj.OnGetInput(inputObj, selectedChoiceText)` is called.

---

## Saved State (iOS Persistence)

The iOS app serializes author globals on auto-save. Two device-confirmed
constraints:

- **Keys of persisted tables must be strings or numbers.** A table-typed key
  (`mem[someTable] = v`) crashes the app on the next save and can corrupt the
  save. `wheriflo lint` warns about table keys.
- **Keep persisted numeric tables dense.** iOS per-screen serialization can
  truncate a sparse numeric table at the first `nil` (`t = {}; t[4] = true` may
  come back empty). Fill from index 1, or key by string.

---

## Geometry

### ZonePoint

```lua
local pt = ZonePoint(latitude, longitude, altitude)
-- or
local pt = Wherigo.ZonePoint(47.6019, -122.3321, 0)
```

| Field | Type | Description |
|-------|------|-------------|
| `lat` | number | Latitude in decimal degrees |
| `lon` | number | Longitude in decimal degrees |
| `alt` | number | Altitude in meters (usually 0) |

### Distance

```lua
local d = Distance(60, "meters")
-- or
local d = Wherigo.Distance(-1, "feet")  -- -1 = infinite
```

---

## Patterns

### Sequential Messages (Message Chain)

Show a series of message boxes one after another:

```lua
function showSequence(steps, finalCallback)
    local idx = 0
    local function next()
        idx = idx + 1
        if idx > #steps then
            if finalCallback then finalCallback() end
            return
        end
        local step = steps[idx]
        Wherigo.MessageBox{
            Text = step.Text,
            Media = step.Media,
            Callback = function(action)
                if step.Action then step.Action() end
                next()
            end
        }
    end
    next()
end

-- Usage:
showSequence({
    { Text = "First message." },
    { Text = "Second message.", Action = function() key.Visible = true end },
    { Text = "Third message." },
}, function()
    -- All messages shown, proceed
end)
```

### Multiple Choice with Native Answer Buttons

Put the full answer strings directly into the input `Choices` field so the
Wherigo app renders each as its own tappable button. Do NOT list the options
inside the question text with `\n` escapes — escaped newlines in quoted
strings collapse to a space on iOS, running the options together.

```lua
function showChoices(question, choices, inputObj)
    inputObj.Text = question
    inputObj.Choices = choices
    Wherigo.GetInput(inputObj)
end

-- Match the selected answer back to a position:
function answerIndex(choices, answer)
    for i, choice in ipairs(choices) do
        if choice == answer then return i end
    end
    return 0
end

q1_choices = {"Frank Lloyd Wright", "Louis Sullivan", "Daniel Burnham"}

inputQ1.OnGetInput = function(input, answer)
    if answerIndex(q1_choices, answer) == 3 then
        -- correct
    else
        -- wrong: re-ask so the player is never stuck
        showChoices("Who designed this building?", q1_choices, inputQ1)
    end
end
```

### Zone Progression (Linear)

```lua
zone1.OnEnter = function()
    -- Do zone 1 stuff...
    zone1.Active = false
    zone1.Visible = false
    zone2.Active = true
    zone2.Visible = true
end
```

### Zone Progression (Parallel — visit in any order)

```lua
local visited = 0

local function checkAllVisited()
    visited = visited + 1
    if visited >= 3 then
        -- All three zones visited, proceed to next act
        finalZone.Active = true
        finalZone.Visible = true
    end
end

zone1.OnEnter = function()
    -- Zone 1 content...
    zone1.Active = false
    checkAllVisited()
end

zone2.OnEnter = function()
    -- Zone 2 content...
    zone2.Active = false
    checkAllVisited()
end

zone3.OnEnter = function()
    -- Zone 3 content...
    zone3.Active = false
    checkAllVisited()
end
```
