Arranger manual
Last updated: 2026-05-02
A pedagogic walk-through of the platform: create an adventure, fill it with puzzles, schedule an event, add teams, and watch them play. Read it once start to finish, or jump to a section from the table of contents.
Welcome
Zonventure is a platform for running location-based adventure games β treasure hunts, team-building events, school field trips. As an arranger you author the puzzles, set up an event, and the players walk around with their phones answering them.
Four words to know
- Adventure β a reusable script. Pages of puzzles in order. Like a book.
- Assignment β a paid licence to run an adventure once for up to N teams. You buy these from the Catalog.
- Event β one specific playthrough at a specific time. Created from an assignment.
- Team β a group of players in an event, identified by a team name. Each player gets their own magic-link to log in as part of the team.
First steps
When you sign up, you create an organization. Everyone in your organization shares the same adventures, assignments, and events.
Verify your email
After signup we email a verification link. You can log in before clicking it, but money-touching actions (buying assignments, scheduling events) are blocked until you verify. The dashboard shows a banner with a "Resend" button if it didn't arrive.
The dashboard
The dashboard is your home: at-a-glance view of upcoming events, ready-to-schedule assignments, and shortcuts to the things you most often do.
Authoring an adventure
Adventures live in the Library. You can author your own, or pick one from the global catalog (the catalog is curated by the platform admin and always free to use).
Create an adventure
- Click "Library" in the sidebar.
- Press "New adventure".
- Give it a title. The slug is auto-derived but editable; once players have started an event with this slug, don't change it.
- Save. You're taken to the puzzle editor.
Adventure metadata
Beyond title and slug, an adventure can carry:
- Intro and outro text β markdown shown to players before the first puzzle and after they finish. Set the tone here ("Welcome to the King's Hunt!") and wrap up with congratulations.
- Colors β two accent colours that override the default pink branding for players in this adventure. Useful for white-labelled events.
- Avatar β an image rendered as the adventure's logo on the dashboard, the live view, and the player welcome screen.
Adding puzzles
A puzzle is one page of the adventure. Players see them in order; only after solving the current page do they advance to the next one.
Title and prompt
The title appears as a heading on the player's screen. The prompt is the body β render it with markdown for formatting, links, lists.
Answer types
- Text answer β list one or more accepted answers. The player's typed answer is matched case-insensitively, with whitespace trimmed. Use multiple entries to allow synonyms ("blue", "blΓ₯", "blueberry").
- No answer β leave the accept list empty. The player sees a "Continue" button instead of an input field. Useful for narrative pages or location-only puzzles.
Hints
Each puzzle can carry an ordered list of hints, each with a delay (in seconds) before it becomes revealable. The "Reveal hint" button on the player's screen unveils them one at a time.
Delays are measured from when the team arrives at the puzzle, so a stuck team naturally gets help, while a fast team never sees the hints.
Images
Attach up to a few images per puzzle. They render under the prompt. Photos of the location, illustrations, or visual clues. Each image is capped at 5 MB.
Location gates
Optionally pin the puzzle to a GPS location with a radius. Players can read the prompt anywhere, but they can't submit the answer until they're inside the radius. The map widget in the editor lets you click to place the pin.
Reordering and disabling
Drag puzzles to reorder. Disable a puzzle to hide it from players without deleting it β useful for swapping seasonal content.
Preview
The "Preview" button on the adventure detail page renders a sandbox playthrough. No team progress is recorded β it's your test rig.
How the event flow fits together
Before scheduling your first event, take a moment to understand the chain:
Adventure β Assignment β Event β Teams β Players
- You author or pick an Adventure (free).
- You buy an Assignment for that adventure: pays for up to N teams.
- You create an Event from the Assignment: ties it to a specific date/time.
- You add Teams to the Event: each team is a group of players.
- You share each Player a magic link: they tap, the event starts, they play.
Buying player-slot assignments
An assignment licences one event for up to N player slots β across however many teams you want to form. Pricing is per-player: the first 15 in any event are free, and each additional player has a fixed cost configured by your operator (currently β¬5).
- Click "Catalog" in the sidebar.
- Pick the adventure you want to run.
- Choose how many players will join (1β500). Buys at or below 15 are free and skip the checkout step entirely.
- Click "Continue". Paid buys go to Stripe Checkout; free buys land you straight on the new assignment.
- After payment, you're sent back to the dashboard with a "Schedule now" prompt on the new assignment.
Creating an event
- Click "Events" in the sidebar, then "Schedule event".
- Pick the assignment you want to use (only unused assignments are listed).
- Give the event a name (eg. "Office team-build, Friday afternoon"). This is for your benefit; players don't see it.
- Pick a date and time. You can also leave it blank and start the event manually later.
- Confirm the no-minors attestation, or pick the COPPA path if you're running for under-13s.
- Save. The event appears in the Events list with state "scheduled".
Adding teams and sharing magic links
Adding a team
- Open the event's detail page from the Events list.
- Click "Add team".
- Type the team name and the player names (one per line, or paste from a CSV).
- Save. Each player slot is created with a fresh magic-link token.
Importing a roster from a file
The "Add teams" dialog has an Upload file mode for the common case where you already have a roster in a spreadsheet or registration system. Pick the file, see the parsed preview ("Recognised as Excel: 12 teams Β· 48 player slotsβ¦"), then click Create. The first column is always treated as the team name; every column after it becomes a player slot.
Four formats are supported, each handled by an independent parser so you can ignore the ones you don't use:
- CSV (.csv) β comma-separated, UTF-8. The lingua franca of every spreadsheet and event-registration platform. Informally specified by RFC 4180.
- Excel (.xlsx) β what Excel, Google Sheets, Numbers, and LibreOffice Calc all save as by default. Formally ECMA-376 / ISO/IEC 29500.
- OpenDocument Spreadsheet (.ods) β LibreOffice Calc's native format. OpenDocument 1.3 / ISO/IEC 26300.
- IOF XML (.xml) β the orienteering data standard. Eventor and most country-level orienteering registration platforms export
EntryListandStartListdocuments in this shape. Schemas at datastandard-v3.
A first-row header is auto-detected when the first cell reads Team, Team name, Name, Lag, or Lagnamn β so you can export from Excel without first stripping your column titles.
If you'd rather paste than upload, the dialog also has a Paste roster mode that takes the same shape as a CSV but as free-form text (separators are comma, tab, semicolon, or colon). Copying a selection from Excel or Google Sheets pastes as tab-separated text, which lands cleanly in this mode.
Importer interface β see internal/teamimport/ in the source if you'd like to ship support for another roster shape.Sharing magic links
Each player's magic link is what they tap to join the event. The event-detail page has a "Team links" panel: copy individual links, copy a CSV of all team members, or copy a single "captain link" per team that the captain forwards to teammates.
Token expiry
Slot tokens expire seven days after the event's scheduled time by default. The team-links panel shows "expires in Nd" so you can spot links that need re-sharing.
Running the event
Start, pause, resume
A scheduled event auto-starts at its time, but you can also start it manually from the event-detail page. Pause the event to freeze player progress (lunch break, weather delay), then Resume to continue.
The activity feed
The event-detail page has a live activity feed β answer attempts (right and wrong), team completions, gate reaches, state changes. Useful for noticing a stuck team in real time.
The live view
Each event has a public-ish "live view" URL β a leaderboard plus optional audience map. Open it on a TV in the lobby; spectators can see team progress without logging in.
The map is OFF by default for privacy. Toggle it on from the event settings if your players are okay with their position being shown to the audience.
Completion
When every team finishes, the event automatically transitions to "completed". Players see the outro text and a stats screen (right answers, distance walked, total time). The event row in your dashboard moves to the "completed" section.
Webhooks: outbound integrations
Webhooks let you hook the platform up to your own systems β push events into Slack, trigger a Zapier flow, update a corporate dashboard, send parents a "your kid's team finished" SMS. The platform POSTs JSON to an HTTPS URL you control whenever something interesting happens.
When to use webhooks
- Show a live leaderboard on a TV in your lobby, branded your way.
- Notify parents (school events) when their child's team finishes.
- Trigger fulfillment in your own ops system when an event completes.
- Pipe events into your analytics warehouse.
The event catalog
Seven event kinds in v1:
event.scheduledβ a new event was created.event.startedβ an event actually started (manual or auto-promote).event.completedβ all teams finished.team.createdβ a new team was added to an event.team.completedβ a team finished. Includes their name and event id.purchase.paidβ an assignment was purchased.purchase.refundedβ a purchase was refunded.
Configure an endpoint
- Click "Webhooks" in the sidebar.
- Click "Add endpoint".
- Give it a name, paste your URL, tick the events you want.
- Save. A secret is shown once β copy it now.
Verifying signatures
Every POST carries an X-Zv-Signature header of the form sha256=<hex> β that's HMAC-SHA256 of the raw request body, keyed by your secret.
Verify in your handler before trusting the body:
# Pseudocode
sig = request.header('X-Zv-Signature') # "sha256=abcd..."
body = request.raw_body # bytes, not parsed JSON
secret = STORED_SECRET
expected = "sha256=" + hmac_sha256(secret, body).hex()
if not constant_time_equal(sig, expected): reject 401
process(parse_json(body))
hmac.Equal in Go, timingSafeEqual in Node, hmac.compare_digest in Python). A naive == leaks bytes via timing.Send a test
Use the "Send test" button on each endpoint to fire a synthetic webhook.test delivery. The customer-side handler can verify the signature without waiting for a real lifecycle event.
Delivery and retry
A delivery worker drains every 5 seconds. On any non-2xx response or timeout we retry with exponential backoff (1m β 5m β 30m β 2h β 8h β 24h, six attempts). After that the delivery is "dead-lettered" β no further retries.
Reply with a 2xx as quickly as you've persisted the event; do the actual work async. The faster you reply, the fewer duplicates we have to enqueue.
Dedupe
Use the X-Zv-Delivery-Id header (a 32-char hex) as your idempotency key. Duplicate deliveries during retries reuse the same id; insert it into a seen_delivery_ids table with ON CONFLICT DO NOTHING and skip if it was already there.
Rotating the secret
If a secret leaks, click "Rotate secret" on the endpoint. A new secret is minted; the old one stops working immediately. Deploy the new value to your verifier first to avoid a brief gap of failed deliveries.
Account, data and deletion
The Settings page has the operational controls for your own account.
- Change password β Requires the current password. Other sessions are signed out on success.
- Change email β We send a confirmation link to the new address. The change only takes effect when you click it.
- Download my data β A single JSON file with your account info plus your organization's runs, teams, adventures, assignments and purchases. The GDPR-friendly export.
- Delete account β If you're the last member, your organization is deleted with you. All runs, teams, adventures, assignments. Cannot be undone.
Self-hosting
Zonventure is open source. You can run your own instance β for an internal company tool, a school, a venue, or just to keep player data inside your own infrastructure. The full operator guide lives in SELFHOSTING.md in the source tree; this section is the orientation.
Quick start
The canonical path is Docker Compose:
git clone https://git.sr.ht/~klahr/zv
cd zv
cp .env.example .env # edit the marked values before going live
docker compose up -d
That brings up Postgres + the app, runs migrations, seeds an initial admin / admin account, and listens on http://localhost:8080. Change the seeded password through the UI on first login. Front the app with TLS (Caddy / nginx / Traefik) before pointing real users at it.
What's the same as the hosted product
- The arranger surface, the play surface, the live audience display β same code, same shapes, same UX.
- Adventures, assignments, events, teams, magic links, recap PDF.
- Webhooks for outbound integration.
- Email verification + password reset (if SMTP is configured).
What's different on self-host
- No Stripe by default. Without
ZV_STRIPE_SECRET_KEYset, the Catalog's checkout flow creates assignments directly β you get the licence without paying, because the operator is you. Wire Stripe in if you want to resell to other organisations on your instance. - No SSO by default. Username + password is the front door. Set
ZV_OIDC_*to enable Google Workspace (or any OIDC provider) sign-in for your domain. The login page picks up the SSO button automatically. - You're the global admin. The seeded admin account sees every org, every adventure, every event on the instance. Treat it like a root password β store it in the same place you keep
POSTGRES_PASSWORD. - Backups are your job. All durable state lives in the
zv_pg_datavolume β adventures, events, uploaded images and audio, recap tokens.docker compose down -vwipes everything. SELFHOSTING.md has a testedpg_dump-based backup recipe. - The marketing landing page is off by default. An unauthenticated visitor lands directly on the login screen. Flip it on via the admin Settings page if you want a public "what is this?" page.
Configuration knobs
Everything is environment-variable driven through the .env file next to compose.yaml. The .env.example in the repo is annotated and grouped by feature: database, mail, OIDC, Stripe, TOTP, legal pages, retention windows. Most defaults are tuned for first-boot ergonomics, not production β read the comments before you put it in front of users.
Updating
Releases are tagged. Pull the new tag, rebuild the image, and migrations run automatically on next start:
git fetch --tags
make upgrade # checks out the latest tag, rebuilds, restarts
Take a Postgres backup before any upgrade. Migrations are forward-only β there is no automated downgrade path.
Getting help
If something doesn't work as described here, or you're unsure how to do something, start with the event's activity feed (any errors are logged there for the team that hit them).
For platform-level support, contact the operator listed at the bottom of the Privacy page.