DreamPark Developer Portal
Download SDK Sign In MyDreamPark

Build downloadable theme parks for the real world.

The DreamPark SDK is a Unity 6 project on GitHub. Clone it, open it, set a content ID, build your attraction, script gameplay in Lua, and publish from the editor — your build downloads to physical DreamPark venues from there.

Public on GitHub

Clone the repo and open in Unity 6. No approval gate to get started.

Lua-first scripting

XLua maps Unity's API 1:1, and scripts push over-the-air without rebuilding.

One-click publish

The editor's Content Uploader packages and ships your build to venues.

Best practices

Read this first — it's the short list that separates attractions that thrive at real venues from ones that don't:

Overview

DreamPark is a mixed-reality theme park platform. It lets real venues run fully virtual attractions — hours of entertainment for visitors carrying nothing but a Quest 3 headset. No wearables, no controllers, no installed hardware per attraction.

What makes that work is the part no one else does: attractions from different developers run side by side in the same venue. An operator sets up and manages their whole lineup in minutes from a simple mobile app, mixing your attraction with everyone else's. Your build downloads to the venue, runs on the headset, and earns you a share of every play.

The SDK is the framework you use to build those attractions. It's a complete Unity 6 project — clone it, open it, and your content lives under Assets/Content/. Everything else in the project is platform plumbing that keeps your build compatible with the venue: headsets, hand tracking, the in-game DreamBand UI, payment kiosks, networking, and so on. The SDK is released alongside the main DreamPark app and you pull updates when we ship them.

Coming from at-home VR or indie? A few things are different in a real venue. Guests are paying customers playing in a shared physical room for somewhere between five minutes and an hour. They can't reset, restart, or troubleshoot — whatever ships has to just work, every time, for everyone. Best practices above covers the small set of things worth knowing up front.

Quick start

  1. Clone the SDK from GitHub. The repo is public — grab it and go. Open it in Unity 6.
  2. Set your content ID. On first launch, a setup popup asks you to pick a unique identifier for your attraction (e.g. coincollector). This renames the starter folder under Assets/Content/ for you.
  3. Open Assets/[StartHere].unity — it opens by itself the first time the project comes up. Press Play — you're in the starter scene.
  4. Create your attraction. Drop an AttractionTemplate, pick a size, drag in props. More below.
  5. Script gameplay in Lua with LuaBehaviour — it does everything MonoBehaviour does, and releases instantly. More below.
  6. Test on a Quest 3. Build to a Quest 3 or Quest 3S for real-hardware iteration.
  7. Publish. Open DreamPark → Content Uploader, sign in (DreamPark → Sign In — email plus a 6-digit code, no password), fill in name/description/logo, and click Compile & Upload. More below.

Requirements

Project structure

The project is one Unity 6 solution. Everything you build lives under Assets/Content/<your-content-id>/; everything else is the SDK and Unity itself. Hover the highlighted rows to see what each core piece does.

Assets/
[StartHere].unityyour editing scene — opens automatically on first launch
DreamPark/SDK source, refreshed when you update — don't modify
Content/
Sample/bundled worked example — browsable and editable, but can't be published
<your-content-id>/the folder name IS your content ID
Prefabs/
Player.prefab Player The persistent player object. It survives across attractions — global systems like score, audio managers, and park-wide state live here as LuaBehaviours, and get injected into your props.
Attraction.prefab Attraction A self-contained experience with an AttractionTemplate on its root — an arcade game, boss battle, or challenge course. It auto-adds a GameArea (guest presence, and the playtime your revenue share is based on) and a MusicArea.
P_MyProp.prefab Prop An interactive object with a PropTemplate on its root — coins, hammers, hazards, decorations. Props live inside attractions, and operators can also place them between attractions around the park. You author these yourself; the bundled Sample has five to copy from.
Previews/auto-generated HD tile art
Scripts/your Lua (*.lua.txt)
ThirdParty/shipped third-party assets
ThirdPartyLocal/import staging — stripped from builds

Your content is made of three pieces: a persistent Player, your Attractions (A_*.prefab), and their Props (P_*.prefab). Multiple content folders can coexist in one project — each is its own independent package, and the Content Uploader's dropdown picks which one to publish. Two names are reserved and can't be published: Sample, and the template placeholder before you rename it.

The template ships a Player.prefab and a starter Attraction.prefab. For fuller examples, open Assets/Content/Sample/ — a complete worked project with two attractions, five props, its own scene and the Lua behind them.

Set your content ID

The first time you open the project, the Content ID Setup popup walks you through picking a unique identifier for your attraction. The ID:

The popup renames the starter folder from the template placeholder to Assets/Content/<your-id>/ and keeps Unity GUIDs intact so nothing in the scene breaks. Pick something short and final — this is the canonical ID used to publish your attraction.

If the popup ever reappears, it means the project doesn't have a valid content ID set. Fill it in again and you're back on track.

Create your first Attraction

Open Assets/[StartHere].unity. You'll see a blank arena — that's your canvas.

An Attraction is a prefab with an AttractionTemplate component on its root (naming convention: A_MyAttraction.prefab). The template auto-attaches a GameArea (the playable boundary that detects guests — and measures the playtime your revenue share is based on) and a MusicArea (an audio zone). Drop one into the scene, then pick a size from the inspector. Sizes are given in feet and run Micro (14×16 ft) → Boutique → Small → Square → Medium → Large → Jumbo (120×150 ft), plus MallCorridor (30×260 ft) for long thin spaces and Custom for anything else.

The Attraction template includes inspector toggles to auto-generate floor and ceiling meshes, so you don't have to model them yourself unless you want to. When it's ready, save it as a prefab under Assets/Content/<your-id>/Prefabs/ — an HD preview is auto-generated for every attraction, so it shows up in the app with tile art automatically. Open A_TestAttraction or A_DreamSequence under Assets/Content/Sample/Prefabs/ to see working examples.

Create your first Prop

Props are interactive bits between attractions — they let operators fill tight hallways with collectable coins, or add environmental detail around core attractions. A Prop is a prefab with a PropTemplate component on its root (naming convention: P_MyProp.prefab), which carries a category (Coin, Block, Hazard, Decoration, etc.) and a calibration Y-offset so the prop sits correctly on the floor at real venues.

Props also work inside your attractions: drag a prop prefab in, position it, and it's hooked in. Behind the scenes the SDK rebuilds the floor gap mesh and re-bakes the NavMesh (what any NavMeshAgent enemies path on) when you change the attraction or its props — you don't manage that yourself. HD previews are auto-generated for props too.

The bundled Sample ships five example props under Assets/Content/Sample/Prefabs/ — open P_Enemy, P_TestProp or P_DreamPortal to see how working props are put together, and copy them into your own folder to build from.

Customize your Player

Player.prefab (under Assets/Content/<your-id>/Prefabs/) is the root player object, already placed and wired into the starting scene. It persists across attractions, so anything you attach to it survives scene loads. Use it for things that should outlive a single attraction — global score state, audio managers, park-wide systems — attached as LuaBehaviours and injected into your props.

About the DreamBand. The DreamBand is DreamPark's core in-game interface — it displays the guest's profile, Wi-Fi status, DreamPoints, and video recording across all content experiences, and it is not a developer-edited component. If you build UI for points, we recommend building around the band, not on top of it: diegetic world-space readouts on props, scoreboards in the room, effects near the wrist.

Scripting in Lua

Write your gameplay in Lua. All of it. The SDK is built around XLua, and there is nothing you can do in a MonoBehaviour that you can't do in a LuaBehaviour:

Add a LuaBehaviour component to any GameObject and point it at a .lua.txt file under Content/<YourGame>/Scripts/:

lua-- coin.lua.txt: a spinning coin any hand can collect
local rotateSpeed = 90  -- degrees / sec

function update()
    self.transform:Rotate(CS.UnityEngine.Vector3.up * rotateSpeed * CS.UnityEngine.Time.deltaTime)
end

function ontriggerenter(other)
    if dp.is_player(other) then
        sparkle:Play()          -- injected ParticleSystem
        scoreManager.add(1)     -- injected LuaBehaviour on the Player
        CS.UnityEngine.Object.Destroy(self.gameObject)
    end
end

Use dp.is_player(other) to test for the guest, and don't hand-roll it. It checks rig ancestry first — anything under the PlayerRig is the player whatever its tag or layer say — and falls back to the Player tag and layer. The version creators tend to write, other.tag == "Player" or other.gameObject.layer == LayerMask.NameToLayer("Player"), is already wrong against our own rig: the Goo collider on BothHands ships Untagged on the Default layer, so that test rejects part of the guest's own hand. There is also no Hand tag at all. Tags are for prop-to-prop dispatch, from the roster in the project's Tags & Layers.

Avoid new C#. Custom C# scripts must be manually reviewed by DreamPark and only run on devices after they ship inside a future app release — expect a significantly longer path to release and slower iteration on every change afterward. Porting existing Unity content? Convert the gameplay scripts to Lua as you bring them in; most C# translates line for line. If you hit something that truly seems impossible in Lua, talk to us before writing C#.

Third-party assets

You'll bring in art packs, audio libraries, shaders, plugins, and the like from the Asset Store or external sources. Park them in your content folder's ThirdPartyLocal/ — it's stripped from builds, so it's a safe staging area to experiment. If an import lands elsewhere, Assets → DreamPark → Move to ThirdPartyLocal relocates it. Note that despite what a few places still claim, this folder is not gitignored — keep it out of your commits yourself.

When you're actually using a third-party asset, move it into your content folder so it ships with your build. Don't shuffle files by hand — the SDK ships a tool that does it for you:

menuDreamPark → Manage Third Party Assets

This tool — ThirdParty Sync — scans your content folder for references, follows them to the third-party files those references point to, and moves them from ThirdPartyLocal/ into Assets/Content/<your-id>/ThirdParty/. It preserves Unity GUIDs so prefab and scene references don't break. The same sync also runs automatically as part of the Content Uploader's Compile & Upload flow, so you can let it handle the move on publish if you'd rather not think about it.

Why bother? Anything in ThirdPartyLocal/ doesn't ship in your build. Anything in your content folder's ThirdParty/ does. The sync tool draws that line cleanly.

Game Storage API

Every guest who plays your attraction gets a small per-player save file scoped to your content ID. High scores, coins, checkpoints, unlock flags, best lap times — that's what it's for. It is deliberately not a blob store: think tens of keys, not megabytes of JSON.

There are two scopes:

The storage variable

You don't import anything or pass IDs around. Every LuaBehaviour gets a storage variable injected automatically, already bound to the attraction it lives in — the SDK walks up the hierarchy to the nearest GameArea, PropTemplate, or LevelTemplate to work out the scope.

luastorage.get("checkpoint", 0)        -- synchronous read, with a default
storage.set("checkpoint", 3)        -- string (≤1 KB) / number / bool only
storage.increment("shots_fired", 1) -- returns the new value
storage.max("high_score", score)    -- set-if-greater — high scores
storage.min("best_time", lapTime)   -- set-if-lower — best times
storage.delete("temp_flag")

storage.game.increment("coins", 5)  -- same ops, game-wide scope
storage.game.get("coins", 0)

storage.isReady()                   -- has the server snapshot landed?
storage.onReady(function() end)     -- fires once it has (reads work before it)

For scripts that live outside any attraction — park-wide systems on Player.prefab, for instance — there's no attraction to bind to. Use the explicit game-scope accessor instead; the injected storage there warns once and no-ops:

lualocal s = dp.storage.game("yourContentId")
s.increment("coins", 5)

Reads are instant, writes are batched

Reads come out of a local cache synchronously — gameplay never waits on a callback to show a high score. The snapshot loads lazily on first access; onReady tells you when the player's real values have arrived, and reads before that return your defaults.

Writes are debounced (~2s) and coalesced into batched requests, so calling increment every frame is fine. Always prefer increment / max / min over read-compare-set. Those three ops are applied server-side, which makes them race-safe: a max("high_score", 50) from this headset can never clobber an 80 the same guest set on another headset five minutes ago.

Works before the guest pairs. Writes queue locally even with no identity bound. When the guest scans their QR code and binds a profile mid-session, the queued ops replay on top of their real save data — increment and max merge correctly rather than overwriting.

Values and limits

Values must be a string (≤ 1024 bytes), a finite number, or a boolean. No nested tables, no arrays, no nil. If you need structure, encode it into separate keys — the caps are per-key-count, so a handful of flat keys is cheaper than one giant JSON string.

LimitValue
Key format[A-Za-z0-9_-], 1–64 characters
String value size1024 bytes (UTF-8)
Keys per scope64
Bytes per scope8 KB
Attraction scopes per game64
Total per game, per player64 KB
Ops per batched request32 (the SDK drains the rest automatically)

Oversized or malformed writes fail locally with a console warning before they ever hit the network — set returns false, numeric ops return 0. Watch the Unity console during testing; a silent no-op is always accompanied by a warning telling you which cap you hit.

Never write unique keys in a loop. storage.set("run_" .. os.time(), x) burns through the 64-key cap in a minute and then silently stops saving. Keys are a fixed schema you design up front, not a log.

Guests can delete their data — design for it

Game storage belongs to the guest, not to your attraction. They can delete any of it, at any time, without telling you — individual keys from their adventure log, a whole game's save from their profile, or everything when they delete their account. That is a deliberate platform guarantee and it is not going away, so treat it as a normal runtime condition rather than an edge case.

In practice this means one rule: every read must survive its key being gone.

The good news is that the defaults-everywhere style this pushes you toward is the same style that makes solo play, offline play, and unpaired guests work. If your attraction handles a fresh player well, it already handles a deletion well.

Worked example

The SDK ships this as Assets/DreamPark/Samples/GameStorage/storage_high_score.lua.txt:

lua-- @var coinValue int 1

local score = 0

function awake()
    -- Reads work immediately (cache / defaults). onReady fires once the
    -- player's persisted values have actually arrived from the server.
    storage.onReady(function()
        local best  = storage.get("high_score", 0)
        local coins = storage.game.get("coins", 0)
        CS.UnityEngine.Debug.Log(string.format("best=%d coins=%d", best, coins))
    end)
end

-- Call once per coin pickup.
function addCoin()
    score = score + (coinValue or 1)
    -- coins are a GAME-scope currency — shared across this game's attractions
    storage.game.increment("coins", coinValue or 1)
end

-- Call when the run ends.
function finishRun()
    -- set-if-greater: safe even if this profile is mid-session on another
    -- headset with a better score.
    local best = storage.max("high_score", score)
    storage.increment("runs_played", 1)
    score = 0
end

REST reference

You shouldn't need these from inside an attraction — the Lua API is the supported surface — but they're what GameStorageAPI calls under the hood, and they're useful if you're building companion tooling:

EndpointWhat it does
GET /app/profile/storage/:contentIdReturns { storage: { game: {…}, attractions: { slug: {…} } }, bytes, caps }
POST /app/profile/storage/:contentIdApplies a batch of ops: { ops: [{ attraction?, key, op, value }] } where op is set, delete, increment, max, or min. Atomic — one bad op rejects the whole batch. Returns the authoritative post-op values.
DELETE /app/profile/storage/:contentIdWipes this player's save for one game.

Auth is the platform API key (or an SDK preview key in the editor) plus an X-Headset-Id header. The player's identity is resolved server-side from the headset binding — a user ID never appears in the URL, and clients can't address another player's data.

Profile API

dp.profile is the read side of the guest's DreamPark identity: who they are, what they own, and how many DreamPoints they're carrying. It's available globally in every Lua script.

CallReturns
dp.profile.isBound()true once a guest identity is attached to this headset.
dp.profile.isLoaded()true once the profile snapshot has been fetched.
dp.profile.onReady(fn)Runs fn when the snapshot is available. Do your profile reads in here.
dp.profile.refresh()Force a re-fetch.
dp.profile.identity()An opaque identity string for display and diagnostics only.
dp.profile.contentId()The content filter currently applied to the snapshot.
dp.profile.getDreamPoints()Current DreamPoints balance (number).
dp.profile.getItem(id) / hasItem(id)An inventory entry by item ID, or a boolean.
dp.profile.getItemByType(t) / hasItemByType(t)First inventory entry whose catalog type matches.
dp.profile.getItemByName(n)First inventory entry by display name.
dp.profile.getAchievement(id) / hasAchievement(id)Progress entry: { achievementId, progress, completed, maxValue, name, contentId }. Careful — hasAchievement is true as soon as the player has any progress. To test whether they finished it, read .completed.
dp.profile.getBadge(id) / hasBadge(id)Badge entry: { badgeId, name, contentId, iconUri, description, awardedAtMs }.

Readers return plain Lua tables, so you can index them directly:

luafunction start()
    dp.profile.onReady(function()
        local wand = dp.profile.getItemByType("wand")
        if wand then
            equip(wand.itemId, wand.modelUri)
        end

        if dp.profile.hasBadge("beta_tester") then
            showSecretDoor()
        end

        local ach = dp.profile.getAchievement("throw_100")
        if ach then
            progressBar.value = ach.progress / ach.maxValue
        end
    end)
end

An inventory entry looks like this:

FieldMeaning
itemIdThe instance ID in the player's inventory. For unique items this is <catalogId>_<random>.
baseItemIdThe catalog ID you defined. Use this to compare against your own constants.
instanceIdSame as itemId — the key of this entry in the inventory map.
name, type, rarity, iconUri, modelUriHydrated from your catalog entry. The catalog is the source of truth for presentation, so renaming an item updates every player instantly.
amountStack size. Always 1 for unique items.
contentIdThe game the item belongs to.
metadataFree-form per-instance data, present only on unique items.

No personal data, by design. The profile snapshot the headset receives deliberately excludes email and any other PII — your attraction never sees it. Display name and avatar exist on the profile for the platform's own UI (the DreamBand shows them), but there is no Lua accessor for them, and building your own player-name UI isn't the intended pattern. Guests playing anonymously always report a DreamPoints balance of 0.

Achievements, badges & items

Three different reward shapes, and picking the right one matters more than the API does:

KindModelUse it for
AchievementProgress toward a goal. Accumulates, then completes."Throw 100 axes", "Finish every level", "Survive 10 minutes"
BadgeBinary. Earned once, displayed forever."Beta Tester", "Wizard's Way Champion", "Opening Week"
ItemAn owned object — stacking, one-of-a-kind, or unique-per-instance — with an icon and optional 3D model.Wands, relics, keys, potions, collectibles

Step 1 — define them in the catalog

Achievements, badges and items are declared in the developer portal before you can award them. Awarding an ID that doesn't exist in the catalog won't error, but the reward will show up unnamed and without art everywhere it's displayed.

From your content's page in the developer portal, use the tabs:

url/developer/content/<your-content-id>/achievements
/developer/content/<your-content-id>/badges
/developer/content/<your-content-id>/items

Each entry needs an ID — letters, numbers, _ and -, up to 80 characters. This is the string you'll pass from Lua, so pick it carefully; it's permanent.

IDs are globally unique across the whole platform, not per-game. If another developer already registered coin, you can't. Namespace yours — wizardsway_coin, axethrow_bullseye — and you'll never have to think about it again.

Fields you can set:

FieldApplies toWhat it does
nameallDisplay name — used in the guest's profile, the adventure log, notifications, and share pages.
descriptionallLonger copy shown on detail panels and share pages. There's an AI-assist button if you're drafting a lot of these.
IconallPNG, JPEG, WebP, GIF or SVG, up to 4 MB. Upload it — the platform serves and caches it.
maxValueachievementsAuto-complete threshold for incremental achievements — when accumulated progress reaches it, the achievement flips to complete on its own. Leave it blank for a one-shot achievement; blank behaves as 1, so a single awardAchievement(id) finishes it.
typeitemsFree-text category, e.g. wand. This is what dp.profile.getItemByType("wand") matches on — very useful for "does the player have any wand at all".
rarityitemsOne of common, uncommon, rare, epic, legendary. Drives the rarity treatment on share pages.
StacksitemsOn by default — repeat awards increase the amount. Untick it for a one-of-a-kind item the player either has or doesn't: a wand, a key, a costume. Awarding a non-stacking item the player already owns becomes a no-op.
3D modelitemsA .glb file up to 25 MB, exposed to your scripts as modelUri.

Step 2 — award them from Lua

luadp.profile.awardAchievement("beat_the_boss")  -- one-shot: marks it COMPLETE
dp.profile.awardAchievement("throw_100", 1)   -- incremental: ADDS 1 progress
dp.profile.awardBadge("axe_master")           -- idempotent
dp.profile.awardItem("wizardsway_coin", 5)    -- stacks: amount goes 5, 10, 15…
dp.profile.awardItem("wizardsway_wand")       -- one-of-a-kind (see "Stacks" below)
dp.profile.awardUniqueItem("relic")           -- a new unique item every call

dp.profile.removeItem("wizardsway_wand", 1)   -- decrement the stack
dp.profile.removeBadge("axe_master")

Most achievements are one-shot, so that's the short form. awardAchievement(id) with no second argument means "the player did this" and marks the achievement complete outright, whatever its threshold. Pass a number only when you're tracking progress toward a goal. completeAchievement(id) is available as an explicit alias if you prefer to spell it out.

The semantics differ per kind, and this is the part worth internalising:

CallRepeat behaviour
awardAchievement(id)Idempotent. Completes the achievement immediately. Completion is sticky — it never un-completes, and the "earned on" timestamp is stamped once and never moves — so calling it again is a no-op. Safe on a trigger a guest can walk back into.
awardAchievement(id, n)Additive. Progress accumulates across calls, so pass the delta, not the running total. The achievement completes on its own once maxValue is reached, and stops accumulating from that point — progress can't run past the threshold.
awardBadge(id)Fully idempotent. Awarding a badge the player already has is a silent no-op. Safe to call on every level load.
awardItem(id, n) — stacking itemAdditive. Each call adds to the stack. There is no dedupe — call it twice and the player has two.
awardItem(id) — non-stacking itemIdempotent. The player gets exactly one. Awarding again is a no-op, and an amount above 1 is clamped. Set by unticking Stacks on the catalog entry.
awardUniqueItem(id)Creates a new unique item every call, with its own instance ID. For loot where each copy is meant to be distinguishable — see the warning below.
removeItem(id, n)Decrements and deletes the row at zero. Idempotent — removing more than the player owns just clears it. For a unique item, pass its full instance ID.
removeBadge(id)Idempotent.

Picking the right item shape

Items come in three shapes, and the distinction is worth getting right up front because it's awkward to change once guests own things:

ShapeHow to get itUse it for
StackingDefault. Leave Stacks ticked.Currency and consumables — coins, potions, ammo. One row, growing amount.
One-of-a-kindUntick Stacks on the catalog entry.Things the player either has or doesn't — a wand, a key, a costume, a permanent upgrade. One row, amount pinned at 1.
UniqueCall awardUniqueItem(id).Loot where every copy genuinely differs — procedurally rolled gear, numbered collectibles. A separate item per grant, each with its own instance ID.

"Unique" does not mean "doesn't stack" — it's closer to the opposite. It's the natural thing to reach for when you want a single wand, but every call creates another distinguishable wand: wand_a3f2, wand_9c11, and so on. Because profile XP counts inventory items, a unique item awarded on every level load also quietly inflates the guest's DreamPark level. For a one-of-a-kind item, untick Stacks on the catalog entry and use plain awardItem(id) instead.

Guard your additive award calls. One-shot achievements, badges, and non-stacking items are idempotent, so repeat calls are harmless. Stacking grants, awardUniqueItem, and progress increments are not — an award inside update(), a trigger volume a guest can re-enter, or a retry loop will happily mint hundreds of copies. Gate those on a local flag or on game storage:

luaif storage.get("got_relic", false) == false then
    dp.profile.awardItem("relic", 1)
    storage.set("got_relic", true)
end

The SDK ships drop-in scripts for all three under Assets/DreamPark/Samples/ProfileAPI/achievement.lua.txt, badge.lua.txt and item.lua.txt. Each exposes its ID as an inspector field and a trigger() entry point, so you can wire an award to a collision or an EasyEvent chain without writing any code.

What happens when you award something

When your writes actually reach the profile

A guest walking around a venue hasn't opted into every attraction installed there. So the SDK holds your profile and save-data writes until the player physically enters one of your attractions — the first time they step into one of your GameAreas, everything you've queued is sent, in order, and every later write goes straight through for the rest of their visit.

Walking back out doesn't close it again. It's a one-time entry, not a presence check.

This is almost always invisible, because the natural place to award something is inside the attraction the guest is standing in. It matters in two cases:

Reads are never gated — dp.profile lookups and storage.get work from the moment your script runs. Only writes wait.

Profile writes are rate limited per guest. Roughly 60 grants a minute and 600 an hour, with a tighter budget on DreamPoints and a hard ceiling on any single points grant. These sit far above what a real attraction does — awards belong on pickups and run-ends, not in update() — so if you're seeing 429 responses, treat it as a bug in your award logic rather than a limit to design around. Game storage has its own budget; the SDK's batching keeps you well inside it as long as you aren't writing every frame.

Scores & the Adventure Log

Every visit to a DreamPark venue produces an Adventure — a timeline of what the guest did, shown on their profile afterwards, summarised in their recap email, and distilled into the park's public activity feed. Scores are the headline act in that timeline.

There is no adventure log API to call. The platform watches your game storage writes and folds them into the timeline for you. Write a score the normal way and the row appears.

What you writeWhat appears in the log
storage.max(key, value)A score row, whatever the key is called — max means "personal record" by definition. Emitted only when the value actually improves, folded to one row per key.
storage.increment or storage.set on a score-like keyA score row carrying the latest value, without the personal-best wording.
everything else — including every storage.min writeFolded into the quieter "game data saved" row.

A key is score-like if its name contains score, best, record or streak (case-insensitive). So high_score, coins_best and win_streak all qualify; coins and checkpoint don't.

storage.min never produces a score row. A best lap time written with min lands in the saved-data row, not the timeline — even though the key looks score-like. If you want a time to show up as a score, write it with max (store it negated, or as "time remaining") or mirror it to a max key.

Name your main score score or high_score and the row reads clean: New high score of 2,450 in Carnival Axe Throw. Any other score-like key gets appended as a dimmed qualifier — coins_best renders … (Coins Best) — which is right for a secondary metric and wrong for your headline number.

How score rows are highlighted

Score rows get treatment no other event type gets:

Personal bests get their own treatment

When a max write actually improves the player's stored best, the row is flagged as a personal best, and the copy changes everywhere:

SurfacePersonal bestOrdinary score
Adventure log"New high score of 2,450 in Carnival Axe Throw""Scored 2,450 in Carnival Axe Throw"
Park activity feed"Aidan set a new high score of 2,450 in Carnival Axe Throw""Aidan scored 2,450 in Carnival Axe Throw"
Share pageEyebrow New High Score, chip High ScoreEyebrow New Score, chip Score

Within a single attraction, a personal best always outranks a non-best row in the feed — even one with a higher number. So a guest who beats their own record gets the moment, not the guest who happened to score higher without improving.

Getting the copy right

Three things you control determine whether these rows read well:

lua-- The whole high-score integration, in one line at the end of a run.
function onRunComplete(finalScore)
    storage.max("high_score", finalScore)
end

Leaderboards. There is no cross-player leaderboard or ranking API on the platform today. max gives you a reliable per-player personal best, and the park activity feed is where scores become social. If you want an in-room scoreboard across guests in the same session, build it on the multiplayer relay rather than on storage.

DreamPoints

DreamPoints are the platform-wide currency — earned across the whole park, spent on real rewards. Your attraction can mint and charge them:

lualocal balance = dp.profile.getDreamPoints()

dp.profile.addDreamPoints(50, "boss_defeated")
dp.profile.spendDreamPoints(20, "revive")

DreamPoints only move on approved attractions. Points are real economic value across the whole chain, so an attraction running in Experimental Mode can't mint or spend them. Both calls still succeed — the balance simply doesn't change — so your game logic runs identically before and after approval. The SDK logs a one-time console warning the first time a call no-ops, and the response carries credited: false / debited: false with a reason.

Design for this: don't gate progression on a spend actually succeeding, and read the balance back rather than assuming your delta landed.

Both calls are gated identically. Points only move when all three of these hold:

Miss any one and the call still returns success with the balance unchanged, so your logic runs the same everywhere. Two things that do behave differently even when fully live:

Because points are real economic value across every attraction in the chain, treat minting as something you tune deliberately rather than sprinkle. Talk to us if you're planning a design where points are the core loop.

Testing player data in the editor

All of the above works in the Unity editor without a headset. Sign in first:

menuDreamPark → Sign In
DreamPark → Profile → Bind to Logged-In User

That runs the same pairing flow a real headset uses and issues a short-lived preview key scoped to your own account. Storage, profile reads, and item/achievement/badge awards all hit the real backend against your own profile — so you're testing the actual code path, not a mock.

Also under DreamPark → Profile: Dump Cache to Console to inspect the loaded snapshot, Clear Identity to simulate an unbound guest, and one-click test award actions.

BehaviourIn editor (preview key)In production (headset)
Game storage read/writeYesYes
Profile readsYesYes
Award item / achievement / badgeYes — tagged as a preview grantYes
Unlock notifications to the guestNoYes (first unlock only)
addDreamPointsSucceeds, changes nothingYes
spendDreamPointsSucceeds, changes nothingYes

If the preview key expires mid-session, re-run Bind to Logged-In User. If awards seem to vanish, check the Unity console first — every rejected write logs the reason.

Multiplayer

Guests at a DreamPark venue are standing in the same physical room. Shared experiences — co-op puzzles, competitive arenas, props that react for everyone at once — are the obvious thing to build, and the SDK is set up so that you can build them without running any infrastructure at all.

Local, over Wi-Fi

Headsets find each other on whatever network they're already on. Venue Wi-Fi, an office router, a phone hotspot — all work.

No servers

One headset quietly elects itself as the relay. Nothing to host, no matchmaking, no accounts, no internet required.

Two components

A NetId on the prop and a LuaBehaviour next to it. Then net_send and onnet are just there.

How sessions work

When your attraction starts up, each headset spends a few seconds listening for an existing session on the local network. If it hears one, it joins. If it hears silence, it starts hosting — and the next headset to boot finds it. There's no lobby, no room code, and nothing for the guest or the operator to do.

At a real venue a DreamBox kiosk may be present and acting as the relay instead. Headsets prefer it and migrate automatically, and the behaviour is identical either way — your gameplay can't tell which kind of host it's talking to, and shouldn't try.

This genuinely works on a phone hotspot. Two Quests tethered to someone's phone will find each other and play. No internet connection is needed at any point — discovery is a LAN broadcast and the relay is on the LAN. That makes it easy to demo your attraction anywhere, and it's why nothing breaks when venue internet has a bad afternoon.

Under the hood every device is a client of one relay, and the host headset is a client of its own. That's a deliberate design choice: the relay does no parsing, keeps no state, and makes no decisions — it just rebroadcasts every message to everyone else, reliably and in order. Nothing about your game lives on the host, which is why losing the host loses nothing but a few hundred milliseconds.

PropertyValue
Peers per session16 (a Wi-Fi budget, not a hard limit — relay traffic scales with the square of the peer count)
Max message size16 KB
Rate limit60 messages per second, per peer
Time to form a session~3–5 seconds from cold start
Who can hostQuest / Android and the Unity editor. iOS is client-only.

Sessions are also scoped, so groups don't accidentally merge. Builds made with the public SDK sit on a separate channel from production builds — your studio test rig will never pull in a live headset on the same Wi-Fi, and vice versa. Within a venue, sessions are further scoped by park.

Net IDs

A network message has to arrive at the right object. When headset A says "this crate is now red," headset B needs to know which of its crates that means. That's what a Net ID is: a number that identifies the same object on every device in the session.

Add the NetId component to any GameObject you want to network. You don't assign the number — it's derived deterministically from where the object sits in the hierarchy, so every device computes the same value independently. There's no ID negotiation, no handshake, and no server handing out handles.

Why derive instead of assign? Because attractions download and spawn independently on each headset, and they don't finish in the same order. Anything that depended on spawn order or on a central authority would produce different answers on different devices. Hashing a path that comes from the prefab asset gives the same answer everywhere, every time, with zero coordination.

The rules that make IDs stable

You mostly don't need to think about this, but it explains the two mistakes people make:

Objects you spawn at runtime are not networked. There is no spawn replication — if your Lua calls Instantiate, that object exists on one headset only, and its Net ID won't match anything anywhere else. Design around objects that already exist on every client: pre-place your networked props, and sync their state (position, colour, active, health) rather than their existence. To "spawn" something, place it disabled and network the enable.

When IDs don't line up

Two warnings in the Unity console tell you exactly what went wrong, and they're worth recognising on sight:

WarningWhat it means
NetId COLLISION on <id>Two live objects hashed to the same number — almost always duplicate scene-root names. Rename one, or set explicitId.
Event for UNREGISTERED NetId <id>A message arrived for an ID that doesn't exist on this device. The sender's and receiver's hierarchies don't match — usually mismatched builds, or a runtime-spawned object.
Event delivered but NO subscribersThe object exists but nothing is listening. The receiving script isn't attached on this client, or it doesn't define onnet.

Messages that arrive before their object has finished registering are buffered and delivered once it does, so you don't have to worry about startup races.

Sending & receiving messages

Put a NetId and a LuaBehaviour on the same GameObject and the messaging API wires itself up. Nothing to import, no manager object to find:

lua-- Send: both arguments are strings. The payload is JSON you build yourself.
net_send("color", '{"r":1,"g":0,"b":0}')

-- Receive: declare this at file scope. It fires on every OTHER device.
function onnet(payload)
    local t = json_parse(payload)
end

The envelope

This is the single most common thing to get wrong, so it's worth being explicit. Your payload is wrapped before it goes on the wire, and onnet receives the whole envelope, not just your fields:

json{"type":"color","payload":{"netId":2847193021,"r":1,"g":0,"b":0}}

So read t.payload.r, never t.r. And because type lives on the envelope, that's where you dispatch:

luafunction onnet(payload)
    local ok, t = pcall(function() return json_parse(payload) end)
    if not ok or t == nil then return end

    local p = t.payload
    if p == nil then return end

    if t.type == "color" then
        apply_color(p.r, p.g, p.b)
    elseif t.type == "door" then
        set_door_open(p.open)
    end
end

Four things to know about delivery

luaif net_send then net_send("color", body) end

A complete working prop

Condensed from the SDK's own sample at Assets/DreamPark/Samples/Multiplayer/lua_touch_color_switch.lua.txt (which adds a few inspector knobs and an oncollisionenter path) — touch a prop with your hand and it changes colour on every headset in the room. That's the whole multiplayer surface area in about thirty lines:

lua-- @var cooldownSeconds float 0.3

local UE = CS.UnityEngine
local renderer = nil
local lastFire = -999

function start()
    pcall(function()
        renderer = self.gameObject:GetComponent(typeof(UE.Renderer))
    end)
end

local function apply_color(r, g, b)
    if renderer == nil then return end
    pcall(function()
        renderer.material.color = UE.Color(r, g, b, 1)
    end)
end

local function on_touched()
    -- Two hand colliders shouldn't double-fire.
    local now = UE.Time.time
    if now - lastFire < cooldownSeconds then return end
    lastFire = now

    local r, g, b = math.random(), math.random(), math.random()

    -- Optimistic local apply — the relay never echoes our own message back.
    apply_color(r, g, b)

    -- Broadcast to everyone else. No-op if no session is up: solo still works.
    if net_send then
        net_send("color", string.format('{"r":%.2f,"g":%.2f,"b":%.2f}', r, g, b))
    end
end

function ontriggerenter(other)
    if dp.is_player(other) then on_touched() end
end

function onnet(payload)
    local ok, t = pcall(function() return json_parse(payload) end)
    if not ok or t == nil then return end
    local p = t.payload
    if p == nil or p.r == nil then return end
    apply_color(p.r, p.g, p.b)
end

Setting it up

Two components on the prop, and you're networked:

  1. Add Component → NetId.
  2. Add a LuaBehaviour on the same GameObject, pointed at your script.
  3. Define function onnet(payload) in that script, and call net_send where you want to broadcast.

That's it — there's no registration step, no manager to update, and no toggle on the AttractionTemplate.

Testing on your machine

You don't need two headsets to develop against this. Run a relay locally:

menuDreamPark → Multiplayer → Start Local Server
DreamPark → Multiplayer → Open Control Panel

The relay comes up on 127.0.0.1:7777 with a web panel on :7780 showing connected peers and live traffic. Then open two Unity editor instances — or one editor and one Quest build — and you'll see state sync between them. The control panel's peer counter is the quickest way to confirm both sides actually joined.

Localhost testing is slightly too forgiving. Traffic to 127.0.0.1 bypasses Android's Wi-Fi broadcast filter, so discovery can pass on your machine and still fail between two real headsets. Always do a final check with two devices on real Wi-Fi before you publish anything that depends on multiplayer.

Patterns & limits

The networking layer is deliberately thin. It moves messages between objects that already exist and does nothing else — no simulation, no authority, no state. That keeps it fast and impossible to desync in confusing ways, but it does mean a few things are your job.

There is no host, as far as your game is concerned

Every peer is equal at the gameplay layer. A Lua script cannot ask whether it's the host, and shouldn't need to — the host is an implementation detail that can change mid-session when someone takes their headset off. Design for last-write-wins: if two guests hit the same button at the same moment, the later message should simply be the answer, not a conflict to resolve.

Nothing is retained, including for late joiners

The relay keeps no history. A guest who joins after the crate turned red sees the crate's original colour until someone changes it again. There is no automatic state sync on join, and no join/leave notification your script can hook.

If a piece of state genuinely matters, rebroadcast it on a slow heartbeat — every few seconds is plenty, and it costs almost nothing:

lualocal nextBeat = 0

function update()
    if net_send == nil then return end
    local now = CS.UnityEngine.Time.time
    if now < nextBeat then return end
    nextBeat = now + 3

    net_send("state", string.format('{"open":%s}', tostring(doorOpen)))
end

Anything that must survive the session belongs in game storage, not on the wire.

Budget your messages

Sixty messages per second, per peer, is the ceiling — and anything over it is dropped silently, which is a miserable bug to chase. More importantly, relay traffic grows with the square of the peer count, and Wi-Fi in a room full of headsets is a genuinely scarce resource. Send on events, not on frames. If you're syncing something continuous like a position, throttle it to 10–20 Hz and interpolate locally.

Messages are also capped at 16 KB. If you're anywhere near that, you're probably sending state that should be derived instead.

One writer per networked property

Pick a single owner for each visual property on a networked object. In particular, never mix a MaterialPropertyBlock writer and a renderer.material writer on the same object — the property block silently masks the material change, and you'll see one headset update while another appears frozen with no error anywhere.

Summary of what isn't there

Not providedDo this instead
Networked spawn / despawnPre-place objects disabled; network the enable/disable.
Late-join state syncRebroadcast important state on a slow heartbeat.
Authority / ownershipDesign for last-write-wins.
Join / leave eventsInfer presence from your own heartbeat messages if you need it.
Unreliable or unordered channelThrottle and interpolate rather than flooding.
Per-player identity on the wirePut an ID you generate into your own payload.
Cross-venue or internet playSessions are local to one network by design.

Debugging a session

Tick Verbose Net Logs on the networking client (DreamBoxClient) — it's live-toggleable while you're in Play mode.

A healthy two-device session looks like this: one side logs a transition to Hosting, the other to ClientPeer, and the host reports Peer connected … (2/16). A host sitting at 1/16 is broadcasting to nobody — that's the signature of a discovery problem rather than a code problem.

SymptomUsual cause
Both devices log HostingThey can't hear each other's beacons. Check they're on the same Wi-Fi network and that client isolation / AP isolation is off on the router — guest networks often block device-to-device traffic entirely.
Ignoring peer beaconWorking as intended — the beacon was filtered by channel, park, or protocol version. The log line includes the reason. An SDK build and a production build will always ignore each other.
Connected, but nothing happensLook for UNREGISTERED NetId (hierarchies don't match between the two builds) or NO subscribers (the receiving script isn't on that object on this client).
net_send is nilNetworking wasn't available when the script woke up. Confirm the object has a NetId alongside its LuaBehaviour, and keep the nil-guard so solo play still works.
Messages stop under loadYou're over 60/second. Throttle at the source.

Publishing

Publishing is one panel: DreamPark → Content Uploader. You'll use it for every release.

Fill in, in the panel itself:

The folder also has to contain at least one Attraction or Prop — until it does, the upload button stays disabled and says so.

Then hit Compile & Upload. That opens the launch window, where you set release notes and confirm build targets: Android and iOS are locked on, and Editor (Mac) and Editor (Windows) are toggles that are on by default and required for an official release. Hit Start — a progress view shows the panel running optimization checks, packaging your build as Unity Addressables, and pushing it to DreamPark. Your attraction goes live to venues from there.

Every upload is a full re-upload by default, which is also the only valid mode for a first publish. Two cheaper modes exist but need the experimental Smart bundling strategy turned on — with the default Legacy strategy the picker doesn't appear at all:

If an upload fails partway, Try Reupload re-sends what's already built without recompiling, and offers to re-send only the bundles that failed last run.

Revenue sharing is governed by your Revenue Sharing Agreement. The economics — what counts as revenue, how the waterfall splits, when you get paid — are explained in plain English here.

Updating the SDK

When DreamPark ships a new SDK version, the editor checks for it on launch and shows a small popup with release notes. You can also check on demand:

menuDreamPark → Check for SDK Updates

The update is delivered as a .unitypackage file. When you click Update Now, the editor downloads it and opens Unity's standard import dialog so you can review what's changing (and uncheck anything you've locally modified). Click Import and you're on the new version.

You can dismiss an update and keep working — it's not blocking. Per the SDK Use Agreement, the expectation is to update within 30 days so your builds stay compatible with current venue hardware and OS.

Optimization

The SDK ships dedicated tools to slim down your build before publishing. They live under DreamPark → Optimization:

Run them before each publish. They make a meaningful difference to download size on real venue networks, and the Content Uploader will surface optimization warnings if you skip them.

Troubleshooting

The Content ID Setup popup keeps reappearing

It shows once per editor session for as long as the template placeholder folder Assets/Content/YOUR_GAME_HERE/ is still on disk — even if you've already created a properly named folder beside it. Delete or rename the leftover placeholder and it stops. The ID format is: starts with a letter, letters and digits only (no dashes, spaces or underscores), 2–64 characters. While the selected folder is the placeholder or fails that format, the Content Uploader shows a Set Content ID button that reopens the popup.

Login popup keeps coming back

The Content Uploader and SDK Update Checker need you to be signed in. If you keep getting bounced to the login popup, your session has likely expired — sign in once and it should hold. If it persists, check that your computer's clock is correct (session validation is time-sensitive).

The sign-in code doesn't arrive

Sign-in is passwordless: DreamPark → Sign In emails you a 6-digit code, and verifying it creates your account if you don't have one — there is no password to reset. If the email is slow, check spam, wait out the 30-second cooldown and hit Resend code. The server allows three sends per address per ten minutes, so don't burn them faster than the mail can land.

"DreamPark/Troubleshooting" menu

The editor includes a built-in menu at DreamPark → Troubleshooting with one-click fixes for common issues: regenerating attraction previews, regenerating Lua codegen, cleaning addressables, fixing broken script references. Try those first.

Builds are huge

Usually traceable to unoptimized or unused assets in your content folder's ThirdParty/. Run the tools under DreamPark → Optimization to find the worst offenders, and use DreamPark → Manage Third Party Assets to make sure you're only shipping what you actually use.

Lua changes aren't picked up

If you've added a new .lua file or renamed something, Unity may need to regenerate the Lua bindings before the new script is visible. Run DreamPark → Troubleshooting → Generate XLua Code and Play again (Verify XLua Codegen checks the result). For OTA pushes to live venues, the next guest session picks up the new Lua — no app rebuild needed.

Get support

The fastest place to reach us: