# Wherigo Cartridge Design Guide

## What is Wherigo?

Wherigo is a GPS-based interactive storytelling platform. Players physically walk to
real-world locations, triggering narrative events, puzzles, and discoveries on their
phone. Think of it as a location-based choose-your-own-adventure.

Cartridges are published on [wherigo.com](https://www.wherigo.com) and played with
Wherigo apps on iOS and Android.

---

## Core Design Principles

### 1. Place Matters

The strongest Wherigo cartridges make the real-world location integral to the story.
Don't just scatter random trivia across a neighborhood — connect what the player
*sees* at each stop to what happens in the narrative.

**Good:** "You stand before the old bank building. Its vault doors are visible through
the window. This is where the tunnel leads."

**Weak:** "Question 3: What year was this building built?"

### 2. Keep It Moving

Players are walking outdoors, possibly in weather, possibly with a phone that's hard
to read in sunlight. Design for the experience:

- **Message length:** 1-3 short paragraphs per message box. If you need more, split
  into a sequence of messages.
- **Choices:** 3-4 options maximum per question. More than 4 is hard to read on a phone.
- **Zones:** 4-7 stops is the sweet spot. Under 4 feels too short. Over 8 and players
  get fatigued.

### 3. Pacing = Walking

The time between zones isn't dead time — it's anticipation. Use it:

- Space zones 2-5 minutes of walking apart (roughly 150-400 meters).
- Don't put two zones within sight of each other unless there's a narrative reason.
- The final zone should feel like arriving somewhere meaningful.

---

## Zone Design

### Sizing

- **ProximityRange:** 30-60 meters is standard. Smaller than 20m causes GPS drift
  problems. Larger than 100m triggers too early.
- **Polygon points:** 4 corners forming a rough rectangle around the target spot.
  Each corner should be ~30m from the center point.
- **OriginalPoint:** The center of the zone — where the player "should" be standing.

### GPS Considerations

- GPS accuracy on phones: typically 3-15 meters, worse near tall buildings.
- Avoid zones that overlap or are directly adjacent.
- Avoid placing zones inside buildings (GPS doesn't work well indoors).
- Avoid placing zones near water or cliff edges (GPS drift + safety).
- Avoid zones that require crossing busy streets to reach.

### Small-Area Cartridges

For cartridges in compact spaces (baseball diamonds, small parks, school campuses,
escape rooms), standard zone sizing won't work. Guidelines:

- **ProximityRange:** Can go as low as 5-10 meters, but expect GPS drift to cause
  false triggers. Test on-site with a real device.
- **Zone polygon:** Keep polygons small (5-15m from center) but set ProximityRange
  to 20-30m so the player doesn't have to stand on the exact spot.
- **Overlap strategy:** In tight spaces, deactivate zones after they're visited
  (`zone.Active = false`) so adjacent zones don't fire simultaneously.
- **Sequential activation:** Only activate the next zone after the current one is
  complete. This prevents GPS drift from triggering zones out of order.
- **Open sky:** GPS works best with clear sky view. Tree cover, buildings, and
  covered areas degrade accuracy. Place zones in the most open spots available.
- **Test on-site:** Small-area cartridges are very sensitive to GPS accuracy.
  What works in the simulator may not trigger reliably on a real device.

### Zone States

| Visible | Active | Effect |
|---------|--------|--------|
| false | false | Zone doesn't exist for the player |
| true | false | Shows on map but doesn't trigger (visited state) |
| true | true | Shows on map and triggers OnEnter when reached |
| false | true | Hidden but triggers (rare — for surprise encounters) |

### iOS Zone Re-Entry

The iOS Wherigo app may re-fire `OnEnter` when the app is backgrounded and
foregrounded while the player is inside a zone. This can cause duplicate
messages, state resets, or items being re-offered.

**Guard pattern:** Add a state check at the top of each `OnEnter` handler to
prevent re-processing:

```lua
local zone1_entered = false

zone1.OnEnter = function()
    if zone1_entered then return end
    zone1_entered = true

    -- Normal zone logic here
    Wherigo.MessageBox{ Text = [[You arrive at the park.]] }
end
```

For zones that should allow re-entry (e.g., a shop the player can revisit),
skip the guard. Only add it to zones where duplicate triggers would break
game state.

### iOS Zone Activation While Player Is Inside

The iOS Wherigo app does **not** fire `OnEnter` when a zone becomes active
while the player is already standing inside it. The player would have to
physically walk out and back in — a poor experience for dynamic zone
activation (e.g., activating the next destination after a game event).

The wheriflo simulator handles this automatically, but for iOS compatibility
you need a cartridge-side helper. The key insight: **activate the zone first,
then check containment and manually call OnEnter if the player is already
inside.**

```lua
function activateZoneOrEnterIfInside(zone)
    zone.Active = true
    zone.Visible = true

    if isPlayerInsideZone(zone) and zone.OnEnter then
        zone.Active = false
        zone.Visible = false
        zone.OnEnter()
    end
end

function isPlayerInsideZone(zone)
    local playerLoc = Wherigo.Player.ObjectLocation
    if not playerLoc then return false end
    return zone:Contains(playerLoc)
end
```

**Why this order matters:** Checking containment *before* activation may
fail on iOS — the client may not report accurate containment for inactive
zones. Always activate first, then check.

Use this helper anywhere you dynamically activate a zone that the player
might already be standing in (e.g., after answering a question, completing
a task, or restarting a game loop).

---

## Narrative Structure

### Linear

```
Start → Zone 1 → Zone 2 → Zone 3 → End
```

Simplest. Good for walking tours and linear mysteries. Each zone unlocks the next.

### Hub and Spoke

```
        Zone 1
         ↑
Start → Hub → Zone 2 → Hub → End
         ↓
        Zone 3
```

Player visits a central location, then explores surrounding zones in any order.
Good for investigation stories. Track visited count to trigger the finale.

### Branching

```
Start → Zone 1 → Zone 2A (if correct)
                → Zone 2B (if wrong) → Zone 2A
```

Player choices affect which zones unlock. Keep branches short — parallel storylines
are exponentially harder to write and test.

---

## Puzzle Design

### Multiple Choice

The most reliable puzzle type for Wherigo. Present 3-4 options.

**Best practice:** Put the full answer strings directly into the input
`Choices` field so the 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
-- Reusable helpers (also included in the wheriflo starter template):
function showChoices(question, choices, inputObj)
    inputObj.Text = question
    inputObj.Choices = choices
    Wherigo.GetInput(inputObj)
end

function answerIndex(choices, answer)
    for i, choice in ipairs(choices) do
        if choice == answer then return i end
    end
    return 0
end

-- Usage:
q1_text = "Who designed this building?"
q1_choices = {
    "Frank Lloyd Wright",
    "Louis Sullivan",
    "Daniel Burnham",
    "Mies van der Rohe",
}

inputQ1.OnGetInput = function(input, answer)
    local idx = answerIndex(q1_choices, answer)
    if idx == 3 then
        -- correct
    else
        -- wrong: offer a retry by re-asking
        showChoices(q1_text, q1_choices, inputQ1)
    end
end

showChoices(q1_text, q1_choices, inputQ1)
```

Match answers by position with `answerIndex()` rather than comparing strings
inline — it keeps the answer key in one place and survives copy edits to the
choice text.

### Observation-Based

The best Wherigo puzzles use what the player can actually see:

- "Count the windows on the second floor"
- "What animal is carved above the entrance?"
- "What color is the door?"

These make the location essential and can't be solved by Googling.

### Item Collection

Items appear in the player's inventory and can gate progress:

```lua
-- Give an item
key.Visible = true

-- Check for an item
if key.Visible then
    -- Player has the key
end
```

---

## Text Input Retry Pattern

When using `InputType = "Text"` for codes, passwords, or free-text answers, you
must handle wrong answers by re-prompting — `OnGetInput` only fires once per
`GetInput` call. If the answer is wrong and you don't call `GetInput` again,
the player is stuck.

```lua
inputCode = Wherigo.ZInput(cart)
inputCode.Id = "input-code"
inputCode.InputType = "Text"
inputCode.Text = "Enter the vault code:"

inputCode.OnGetInput = function(input, answer)
    if answer == "70421" then
        Wherigo.MessageBox{ Text = "The vault door swings open!" }
    else
        Wherigo.MessageBox{
            Text = "Wrong code. Try again.",
            Callback = function()
                Wherigo.GetInput(inputCode)  -- re-prompt
            end
        }
    end
end
```

The key is calling `Wherigo.GetInput()` again inside the wrong-answer branch's
`Callback`. This creates a retry loop that continues until the player enters the
correct answer.

### Free-text answers on iOS: validate inline, never forward

Field testing on real iOS devices surfaced a rule the simulator cannot
reproduce: **compare the answer directly inside the input's own `OnGetInput`
handler. Do not forward the `answer` value through a helper function or an extra
callback layer before comparing it.** Give each prompt its own `Wherigo.ZInput`.

A generic wrapper like this looks perfect in `wheriflo play` but has silently
rejected correct answers on iOS:

```lua
-- BROKEN ON iOS: the answer is forwarded through a second callback layer.
function askText(question, cb)
    inputText.OnGetInput = function(input, answer)
        cb(answer)          -- forwarding the raw answer -> unreliable on device
    end
    Wherigo.GetInput(inputText)
end
```

Instead, compare inside the dedicated input's `OnGetInput`. Two more device-verified
rules:

- **Never call a string function on `answer`.** `string.upper(answer)`,
  `string.find(answer, ...)`, `string.gsub(answer, ...)` have all misbehaved on
  iOS — in one repro a normalizer that ran `string.gsub` on the answer collapsed
  *every* input to the same value, so the gate accepted anything. String
  operations on **your own constants are fine**; it is specifically string-library
  calls applied to the `answer` object that are unreliable.
- **For an exact-match token (password, code, combo), enumerate `==`.** Build the
  accepted variants from your own constants and compare `answer` with bare `==`.
  Do **not** use a `string.find` substring fallback for exact tokens — it accepts
  trailing junk (a gate keyed to `swordfish` accepts `swordfishhh`; `7596` accepts
  `75960`).

```lua
-- RELIABLE exact-match: variants built from constants, compared with bare ==.
local PASSWORD = "swordfish"
local PASSWORD_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, #PASSWORD_VARIANTS do
        if answer == PASSWORD_VARIANTS[i] then
            enterTheFloor()
            return
        end
    end
    inputPass.Text = "That is not it. Try again."
    Wherigo.GetInput(inputPass)   -- retry
end
```

For a numeric entry, enumerate the string forms instead of `tonumber(answer)`:

```lua
inputAge.OnGetInput = function(input, answer)
    for n = 0, 120 do
        if answer == tostring(n) then handleAge(n); return end
    end
    Wherigo.GetInput(inputAge)
end
```

**Partial / phrase matching** (e.g. "does the sign mention the parks department?")
is the one case where a substring test is appropriate — but keep the string
operations on **constants**, not on `answer`, and accept that extra text will
match:

```lua
if answer == "PARK DEPT" or answer == "Park Dept" or answer == "park dept" then
    -- exact hits (including the iOS auto-capitalized form)
    ...
end
```

Always give wrong answers a retry path, and smoke-test every text input on a real
device before publishing. `wheriflo lint` warns when a free-text `OnGetInput`
forwards `answer` to another function or calls a string-library function on it.

---

## Scoring

Track correct answers with a counter variable:

```lua
local score = 0

inputQ1.OnGetInput = function(input, answer)
    if answer == "B" then
        score = score + 1
        Wherigo.MessageBox{ Text = "Correct!" }
    else
        Wherigo.MessageBox{ Text = "Not quite." }
    end
end
```

Show a final report at the end:

```lua
local report = string.format("Score: %d / %d", score, total_questions)
Wherigo.MessageBox{ Text = report }
```

---

## Images & Media

### Cartridge Icon

The cartridge icon appears in the player app's listing at roughly 50-60px. The
iOS Wherigo app downscales aggressively and uses lossy rendering, so design for
clarity at small sizes:

- **Use PNG format** — lossless compression avoids double-JPEG artifacts
- **256x256 pixels** — higher resolutions don't improve display quality
- **Bold, simple shapes** — think app icons, not detailed illustrations
- **High contrast** — bright colors on a solid background
- **Avoid text smaller than ~30pt** — it becomes unreadable at icon size
- **No fine lines or gradients** — they blur into mush

**Good icon designs:** A single bold symbol (compass, magnifying glass, key,
skull), a large letter or number, a simple silhouette, a bold geometric shape.

**Bad icon designs:** Detailed photographs, small text, thin borders, complex
scenes, subtle color differences.

### In-Game Images

Images in message boxes, zones, characters, and items display at full width on
the phone screen:

- **640x480 pixels** is a good default for content images
- **PNG** for graphics, maps, and diagrams (lossless, sharp edges)
- **JPG** (quality 90+) for photographs (smaller file size)
- Keep individual images under ~200KB for fast loading
- Total cartridge size should stay under a few MB

**Note:** The Groundspeak compiler reprocesses images when building the GWC.
JPG images may show additional compression artifacts after compilation. PNG
images survive compilation with better quality. For the sharpest results, use
PNG wherever possible.

### Splash Screen

The splash screen shows when the cartridge first loads. Assign a ZMedia to
`cart.Media`:

```lua
cart.Media = imgSplash    -- displayed on cartridge load screen
cart.Icon = imgIcon       -- displayed in cartridge listing
```

---

## Completion Code

Every compiled cartridge (.gwc) has a unique **completion code** that players
enter on wherigo.com to prove they finished the cartridge. Without it, they
can't log their find.

### The canonical pattern

```lua
function showCompletion()
    cart.Complete = true
    local code = Player.CompletionCode or "(code unavailable in simulator)"
    local shortCode = string.sub(code, 1, 15)
    Wherigo.MessageBox{
        Text = [[Congratulations! You completed the cartridge!

Your unlock code: ]] .. code .. [[

(If the full code is rejected on wherigo.com, try the first 15 characters: ]] .. shortCode .. [[)

Save this code to log your find on wherigo.com.]],
    }
end
```

### Critical details

- **`cart.Complete = true` is load-bearing.** Without it, no completion code is
  generated — even if you display `Player.CompletionCode`. Set it *before*
  reading the code (the runtime populates the code when Complete is set).
- **Show the code before closing the cartridge.** Some Wherigo apps close the
  cartridge immediately when `Complete` is set, so display the code in the
  same flow.
- **The code is per-player, per-download.** When a signed-in player downloads
  the .gwc from wherigo.com, the server customizes the code for that player.
  The wheriflo simulator always shows `TEST-CODE-12345`, and a locally built
  .gwc embeds a random placeholder — neither is a real, loggable code.
- **The 15-character fallback is community practice.** Some Wherigo player
  implementations or wherigo.com itself have historically rejected the full
  code while accepting the first 15 characters. Defensive cartridges show both.
- **Persist the code in an inventory item.** If the player dismisses the
  message accidentally, they can retrieve the code from their inventory:
  ```lua
  itemCode.Description = "Your code: " .. code
  ```

### Testing the completion flow

1. **Local test (wheriflo play):** The code will be `TEST-CODE-12345`. Verify
   the message displays correctly and `cart.Complete` is set.
2. **Real test:** Upload the .gwz to wherigo.com → download the .gwc as a
   logged-in user → play → verify the displayed code unlocks the cartridge
   on the site. There is no shortcut for this — the per-player code is only
   generated during the wherigo.com download step.

---

## Protecting Your Cartridge from Decompilers

A published `.gwc` is compiled Lua bytecode, and public tools can decompile it
back to readable Lua. The most capable one
([cachesleuth.com/tools/wherigo](https://www.cachesleuth.com/tools/wherigo/))
will, from a single upload, decompile the source, decode Urwigo/Wherigo Builder
obfuscation, and **scan for the final coordinates** — ranking them by
confidence. It flags highest when the *same* coordinate appears in both a
message string and a zone point. Any cartridge that follows the obvious
patterns is trivially spoiled.

Perfect defense is impossible — the player app has to execute the bytecode, so
anything hidden must be un-hidden at runtime. The realistic goal is to make
extraction **meaningfully harder than the schemes these tools already crack**,
so a casual decompiler gives up. `wheriflo lint` warns about the two patterns
these tools weight highest (see below).

### What the scanners look for (and how to avoid it)

**1. Don't put coordinates in message or description text.**
Decompilers scan Lua *string literals*. A line like:

```lua
Wherigo.MessageBox{ Text = "The cache is at N47 39.574 W122 21.686" }
```

is the single easiest thing to extract. Instead, **reveal the coordinates in an
image** — an embedded photo of a signpost, scoreboard, or handwritten note with
the numbers painted on it. Scanners read string literals; they don't OCR your
images.

```lua
-- Coordinates live in the picture, not in any string the decompiler can read.
mediaFinalClue = Wherigo.ZMedia(cart)
mediaFinalClue.Id = "media-final-clue"
mediaFinalClue.Resources = { { Type = "png", Filename = "final-clue.png" } }

Wherigo.MessageBox{ Text = "Read the sign carefully.", Media = mediaFinalClue }
```

**2. Prefer a completion-code unlock over a final-coordinate reveal.**
The strongest defense is to not ship the final coordinates in the cartridge at
all. Gate them behind `Player.CompletionCode`: the player completes the
cartridge, gets their code, and the real cache coordinates live only on the
geocache listing (unlocked by that code). See **Completion Code** above. This is
the **Reverse Wherigo** pattern — the game's codes open a physical container or
unlock the listing; the coordinates were never in the bytecode.

**3. If you must have a final zone, don't use literal coordinates for it.**
A zone named "final"/"cache"/"victory" with a literal `ZonePoint(47.6019,
-122.3317, 0)` is the second pattern the scanner ranks highest — and if that
same number also appears in a message, it's flagged "Extremely High". Options,
strongest last:

- Compute the point at runtime from game state rather than writing it literally.
- Split the latitude and longitude across non-adjacent variables so no single
  line contains the pair.
- Store the coordinates XOR-encoded against the cartridge GUID and decode them
  only after `cart.Complete = true`. Add a couple of decoy `ZonePoint`s
  elsewhere to muddy the confidence ranking.

### Let wheriflo do it: `--obfuscate-final-coords`

Rather than hand-rolling the encoding, build with:

```bash
wheriflo build --obfuscate-final-coords cartridge.lua
```

wheriflo finds the final zone — either the one you tag with a `--@final-zone`
comment, or a zone named `final`/`cache`/`victory` — and rewrites its literal
`ZonePoint` coordinates into an XOR-encoded blob that is decoded at load time.
The built `.gwc` *and* the `.gwz` you upload to wherigo.com then contain no
literal coordinate, and a few decoy points are added to dilute the scanner's
confidence ranking. Your `.lua` source is left untouched — the transform happens
only in the build. Mark the zone explicitly like this:

```lua
--@final-zone
zoneFinal = Wherigo.Zone(cart)
zoneFinal.Name = "The Cache"
zoneFinal.OriginalPoint = ZonePoint(47.6019, -122.3317, 0)
```

This is not unbreakable (the runtime still reconstructs the point), but it
defeats the one-click static scanners.

Two caveats: obfuscation protects only the zone(s) it rewrites, so make sure the
same coordinate does not also appear unobfuscated elsewhere (the starting
location, another zone, or a message) — the linter's coordinate checks help you
catch that. And verify the final experience end-to-end after enabling it.

### The honest caveat

None of this stops a determined attacker with a debugger — the runtime must
produce the real coordinates eventually. But moving coordinates out of string
literals and out of literal final-zone points breaks the current **one-click**
extraction workflow, which is the realistic threat for most cartridges. When in
doubt, combine defenses: coordinates-in-image **and** completion-code unlock.

---

## Managing Cartridge Size

For content-heavy cartridges (many characters, long descriptions, trivia
questions, branching dialogue), the Lua bytecode can grow large. While our
testing found no hard size limits in the Groundspeak compiler (1MB source,
5MB GWZ, 2000 functions, and 200 zones all compiled successfully), keeping
cartridges lean is good practice.

### What costs the most space

**String literals** are the biggest bytecode consumers. Long descriptions,
flavor text, and multi-line dialogues add up fast. Trimming verbose text is
the fastest way to reclaim space.

### Deduplication

If the same text appears in multiple places (e.g., a player description shown
on both a selection screen and a confirmation screen), build it dynamically
from a shared data table:

```lua
-- BAD: same text duplicated in bytecode
Wherigo.MessageBox{ Text = "Watson: tall, loyal, medical background" }
-- ...later...
Wherigo.MessageBox{ Text = "Watson: tall, loyal, medical background" }

-- GOOD: shared data, referenced by name
local players = {
    watson = { name = "Watson", desc = "Tall, loyal, medical background" },
}
Wherigo.MessageBox{ Text = players.watson.desc }
```

Extract repeated logic (question handlers, restart flows, stat lookups) into
shared helper functions. Even small helpers save hundreds of bytes when the
pattern repeats 3-4 times.

### What doesn't help

**Comments and blank lines** are stripped during bytecode compilation. Removing
them saves source file size but zero bytecode. Don't waste time on cosmetic
cleanup if you're trying to reduce compiled size.

### Groundspeak compiler failures

If `wheriflo compile` fails with a vague error like "invalid GWZ file," it's
almost certainly one of these known Groundspeak compiler bugs — not a size limit:

1. **Apostrophes in Lua comments** — crashes the compiler. Apostrophes in
   strings (quoted and `[[ ]]`) are perfectly safe — use contractions like
   "don't", "he's", "it's" freely in game text. Only `--` comment lines
   are affected. Use `wheriflo lint` to detect.
2. **Functions >~4KB with nested closures and long strings** — triggers a C#
   `ArgumentOutOfRangeException`. Split large functions into smaller ones.
3. **Non-ASCII encoding artifacts** — invisible bytes from copy-pasted content.
   Generate Lua programmatically or ensure pure ASCII output.

---

## On-Location Verification (Optional)

For geocache-style cartridges where the author wants to encourage physical
presence, consider adding an early verification prompt that can only be
answered on-site. This discourages remote simulator-only play without
punishing legitimate players.

### Guidelines

- **Ask early** — place the gate before gameplay begins, so remote players
  discover it immediately rather than midway through.
- **Keep it diegetic** — frame the question as part of the story, not a
  security check. A character asks a question, a sign needs reading, a code
  is visible on a physical object.
- **Use stable details** — ask about something unlikely to change: wording
  on a permanent plaque, a building color, a utility marker number, a
  sculpture detail. Avoid seasonal decorations or temporary signs.
- **Normalize answers** — compare case-insensitively, strip whitespace, and
  accept common variants (abbreviations, with/without punctuation).
- **Provide a friendly retry** — if the answer is wrong, re-show the prompt
  with an encouraging message rather than ending the cartridge.
- **Use free-text input** — `InputType = "Text"` is harder to brute-force
  than multiple choice. The wheriflo simulator supports text input testing.

### Example

```lua
inputLocationCheck = Wherigo.ZInput(cart)
inputLocationCheck.Id = "input-location-check"
inputLocationCheck.InputType = "Text"
inputLocationCheck.Text = [[The hot dog vendor squints at you.

"Before I let you through, answer me this: what's written on the metal
disk at your feet?"]]

inputLocationCheck.OnGetInput = function(input, answer)
    -- Validate inline here — do NOT forward `answer` to a helper, and do NOT
    -- call string functions on `answer` (both are unreliable on real iOS).
    -- Enumerate the accepted spellings, built from constants, and compare with
    -- bare ==. Include the iOS auto-capitalized form.
    if answer == "PARK DEPT" or answer == "park dept" or answer == "Park Dept"
       or answer == "PARKS DEPARTMENT" or answer == "Parks Department" then
        -- Correct — proceed to gameplay
        Wherigo.MessageBox{
            Text = [[The vendor grins. "You're in the right place. Welcome."]],
            Callback = function() startGameplay() end,
        }
    else
        -- Wrong — retry
        inputLocationCheck.Text = [[The vendor shakes his head.

"That's not it. Take another look at the ground and try again."]]
        Wherigo.GetInput(inputLocationCheck)
    end
end
```

> **iOS NOTE:** Two rules make free-text checks reliable on the iOS Wherigo app.
> **(1) Validate inline** inside the input's own `OnGetInput` — never forward the
> `answer` through a helper or extra callback layer. **(2) Never call a string
> function on `answer`** (`string.upper(answer)`, `string.find(answer, ...)`,
> `string.gsub(answer, ...)` have all misbehaved on device). For exact-match
> tokens, enumerate `==` against variants built from your own constants (string
> ops on your *own* strings are safe). Avoid a `string.find` substring fallback
> for exact tokens — it accepts trailing junk (`7596` would accept `75960`).

This pattern is lightweight for on-site players (one quick observation) and
difficult to bypass remotely. It also gives the cartridge a natural story
hook.

---

## iOS Sim-Parity Gotchas

These are behaviors where the wheriflo simulator shows one thing and the real
iOS Wherigo app does another, so a cartridge can pass every sim test and still
ship broken. All are device-confirmed; `wheriflo lint` warns about each.

### Persisted globals: keys must be strings or numbers

The iOS app serializes author globals on auto-save (including when returning to
the app menu). If any persisted table has a **table-typed key**, the save
**crashes the app instantly**, and the corrupt save can crash subsequent
reopens until the player starts a fresh game. The failure surfaces far from its
cause (it often looks like a zone re-entry crash).

```lua
-- CRASHES on the next save: a table used as a key
mem[POOL] = 1            -- POOL is a table

-- SAFE: string or number keys
mem["pool"] = 1
```

Related: the iOS per-screen serialization also **truncates sparse numeric
tables at the first nil** (`t = {}; t[4] = true` can come back empty). Keep
persisted numeric tables dense, or key them by string.

### No concurrent modals; a replaced MessageBox never fires its Callback

On iOS, raising a `MessageBox`/`GetInput` while another message is on screen
**replaces** the current one — and the replaced box's `Callback` **never
fires**, dead-ending the flow with no error. The simulator queues them, so the
flow works in the sim. Invariant: **never let two flows raise modals
concurrently.** The common footgun is `OnStart` activating a zone the player may
be standing in (via `activateZoneOrEnterIfInside`, which fires `OnEnter`
synchronously) *and* showing an intro message — the OnEnter message clobbers the
intro. Fix: move the activation into the intro message's `Callback`:

```lua
cart.OnStart = function()
    Wherigo.MessageBox{
        Text = "Intro...",
        Callback = function() activateZoneOrEnterIfInside(zoneStart) end,
    }
end
```

### Choices render as a picker, not buttons

Both major players render a multiple-choice `ZInput` as a **picker** (iOS
scroll-wheel with a Done button; Android dropdown Spinner), not tappable
buttons. Two consequences:

- **Write "select"/"choose", never "tap"/"click".** "Tap a card to hold it" is
  meaningless on a scroll wheel.
- **Keep choice labels short and single-spaced.** Long labels truncate on the
  wheel, and **2+ consecutive spaces render as a literal `&nbsp;`** on the iOS
  picker (message copy is unaffected — labels only).

### Tasks are ordered alphabetically by Name, not declaration order

The iOS Tasks page sorts tasks **alphabetically by `Name`**, so declaration
order is not what players see. If order matters, prefix names with numbers,
which double as a sequence cue:

```lua
task1.Name = "1. Infiltrate the casino"
task2.Name = "2. Crack the safe"
```

---

## Testing Checklist

Before publishing, verify:

1. **Every zone triggers correctly** — teleport to each zone and confirm OnEnter fires
2. **Every choice works** — test correct AND incorrect answers at every decision point
3. **No dead ends** — every path leads somewhere; no state where the player is stuck
4. **No orphaned objects** — every zone that's activated is eventually deactivated or
   leads to the ending
5. **Cartridge completes** — show `Player.CompletionCode` to the player, then set `cart.Complete = true`
6. **Build succeeds** — `wheriflo build` produces valid .gwc
7. **Field test** — walk the actual route at least once before publishing

### Common Bugs

- **Zone never activates:** Forgot to set `zone.Active = true` and `zone.Visible = true`
- **Callback chain breaks:** A MessageBox `Callback` is `nil` — the player dismisses
  and nothing happens
- **Input without OnGetInput:** Player makes a choice but nothing handles it
- **Wrong answer dead end:** Wrong answer path doesn't offer a retry or advance
- **Zone overlap:** Two zones trigger simultaneously — unpredictable behavior

---

## GWC Build Requirements

The `wheriflo build` command handles all of this automatically, but for reference:

1. **Lua 5.1 only** — no Lua 5.2+ features
2. **No `require` except `require "Wherigo"`** — the sandbox forbids other modules
3. **NEVER set `Media = nil` or `Icon = nil`** — this crashes the iOS app. Omit these properties entirely.
4. **Bytecode must be 32-bit** (`size_t=4`) for iOS/Android compatibility
5. **PlayerID must be 8 bytes** in the GWC header
6. **All strings must be UTF-8** null-terminated in the GWC binary

### Groundspeak Compiler Compatibility

If using the Groundspeak (wherigo.com) SOAP compiler instead of the local toolkit:

7. **No apostrophes in Lua comments** — `'` inside `--` comment lines crashes the compiler. Apostrophes in strings are fine. Strip them with a post-processing pass. This is the most common issue for English-language cartridges — contractions like "don't", "you're", "Wilson's" in comments will all trigger the crash. Use `wheriflo lint` to catch these before compiling.
8. **Keep functions under ~4KB** — functions with many nested closures and long `[[ ]]` strings exceeding ~4KB cause a C# ArgumentOutOfRangeException. Split large `showSequence()` calls into chained functions of ~3 messages each.
9. **Generate Lua programmatically** — copy-pasted files can carry invisible encoding artifacts. Use Python to generate every byte fresh. Output must be pure ASCII.
10. **Em-dashes:** Use Lua escape `\226\128\148` in quoted strings, `--` in `[[ ]]` long strings.

---

## Publishing

1. Build with `wheriflo build your_cartridge.lua` (produces a `.gwz`)
2. Validate with the official compiler: `wheriflo compile your_cartridge.lua`
3. Create an account on [wherigo.com](https://www.wherigo.com)
4. Run `wheriflo publish your_cartridge.lua` — it opens the wherigo.com
   upload page and copies the `.gwz` path to your clipboard
5. Upload the `.gwz` file (wherigo.com compiles it server-side; players
   download the per-player `.gwc` from the listing)
6. Fill in the listing: title, description, difficulty, terrain
7. Set the starting coordinates
8. Mark as "active" when ready for players
9. Run `wheriflo publish` again with the cartridge URL to save the GUID
   so future updates open the edit page directly
