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:
- Write gameplay in Lua. XLua maps Unity's API 1:1 and
LuaBehaviourdoes everythingMonoBehaviourdoes — and Lua ships over-the-air, instantly. New C# has to be manually reviewed and waits for a future app release. Scripting in Lua. - Build for hand tracking and physical movement. Guests walk around a real room with their hands. Don't design around controllers, keyboard input, or stationary play — none of that exists at the venue.
- Design for 5 to 60 minute sessions. Most attractions sit somewhere in that range. Long onboarding, slow tutorials, and complex menus are friction in a setting where guests are paying for time.
- Whatever ships has to just work. Guests can't reset, restart, or troubleshoot. Test on real Quest 3 hardware before publishing.
- Keep builds lean. Use the Optimization tools and Manage Third Party Assets to strip out anything you're not using. Every megabyte downloads to every venue.
- No undisclosed data collection. Per the SDK Use Agreement, analytics, telemetry, or tracking inside your attraction must use the platform-provided analytics. Don't roll your own.
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
- Clone the SDK from GitHub. The repo is public — grab it and go. Open it in Unity 6.
- 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 underAssets/Content/for you. - Open
Assets/[StartHere].unity— it opens by itself the first time the project comes up. Press Play — you're in the starter scene. - Create your attraction. Drop an AttractionTemplate, pick a size, drag in props. More below.
- Script gameplay in Lua with
LuaBehaviour— it does everythingMonoBehaviourdoes, and releases instantly. More below. - Test on a Quest 3. Build to a Quest 3 or Quest 3S for real-hardware iteration.
- 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
- Unity
6000.0.39f1. The SDK is pinned to this exact version — newer 6000.x releases aren't supported yet. - Unity build support modules: Android (with its OpenJDK and Android SDK & NDK sub-modules) and iOS — the uploader builds both on every release and neither can be switched off. Add Mac and Windows build support too: those editor targets are on by default in the upload window and are required for an official release.
- Git LFS. The SDK's art, audio, models and native plugins live in LFS. Run
git lfs installbefore cloning — without it the clone appears to work but every binary arrives as a text placeholder. - A DreamPark developer account for the Content Uploader and publishing. Sign-in is passwordless: enter your email, then the 6-digit code we send you — verifying it creates the account if you don't have one. Developer program details here.
- Git. You'll clone the SDK from GitHub.
- A Meta Quest 3 or Quest 3S for testing on real hardware.
- macOS, Windows, or Linux all work for development.
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.
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:
- Must start with a letter
- Can contain letters and numbers only
- Must be between 2 and 64 characters
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:
- XLua is a 1:1 map of Unity's API. Every Unity type, method, and property is reachable through the
CSnamespace:CS.UnityEngine.Vector3.up,CS.UnityEngine.Time.deltaTime,CS.UnityEngine.Object.Instantiate(prefab). If you know how to write it in C#, you already know how to write it in Lua — the call is the same, just prefixed. LuaBehaviourdoes everythingMonoBehaviourdoes. The same lifecycle (awake,start,update,ondestroy), the same physics callbacks (oncollisionenter,ontriggerenter), the same component access viaself.transform/self:GetComponent(...), plus inspector-injected references (GameObjects, AudioClips, other scripts) that appear as globals in your script.- Lua releases instantly. Scripts are data, not compiled code — they push over-the-air to live venues without an app rebuild. Guests pick up new behavior on their next session.
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:
- Attraction scope (the default) — keyed to the individual attraction the script lives under. Two attractions in the same content ID each get their own
high_score. - Game scope —
storage.game.*. Shared across every attraction you ship under one content ID. Use it for currency, account-wide progress, and cross-attraction unlocks.
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.
| Limit | Value |
|---|---|
| Key format | [A-Za-z0-9_-], 1–64 characters |
| String value size | 1024 bytes (UTF-8) |
| Keys per scope | 64 |
| Bytes per scope | 8 KB |
| Attraction scopes per game | 64 |
| Total per game, per player | 64 KB |
| Ops per batched request | 32 (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.
- Always pass a default.
storage.get("level", 1), neverstorage.get("level")followed by arithmetic. A deleted key reads as your default, exactly like a first-time player. - Never treat "has data" as "has permission". If a guest deletes the key that recorded they finished the tutorial, they see the tutorial again. That's correct and survivable. If instead it strips an item they paid DreamPoints for, that's a bug you built — keep entitlements in their inventory, which is a separate system with its own rules.
- Don't split one fact across several keys. Deletion is per-key, so a guest can remove
quest_stagewhilequest_reward_claimedsurvives. If those two disagree, your quest is stuck. Prefer one key that fully describes a state over several that only make sense together. - A returning player can look brand new. Your "welcome back" path and your first-run path should both lead somewhere sensible, because either can fire for the same guest on consecutive visits.
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:
| Endpoint | What it does |
|---|---|
GET /app/profile/storage/:contentId | Returns { storage: { game: {…}, attractions: { slug: {…} } }, bytes, caps } |
POST /app/profile/storage/:contentId | Applies 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/:contentId | Wipes 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.
| Call | Returns |
|---|---|
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:
| Field | Meaning |
|---|---|
itemId | The instance ID in the player's inventory. For unique items this is <catalogId>_<random>. |
baseItemId | The catalog ID you defined. Use this to compare against your own constants. |
instanceId | Same as itemId — the key of this entry in the inventory map. |
name, type, rarity, iconUri, modelUri | Hydrated from your catalog entry. The catalog is the source of truth for presentation, so renaming an item updates every player instantly. |
amount | Stack size. Always 1 for unique items. |
contentId | The game the item belongs to. |
metadata | Free-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:
| Kind | Model | Use it for |
|---|---|---|
| Achievement | Progress toward a goal. Accumulates, then completes. | "Throw 100 axes", "Finish every level", "Survive 10 minutes" |
| Badge | Binary. Earned once, displayed forever. | "Beta Tester", "Wizard's Way Champion", "Opening Week" |
| Item | An 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:
| Field | Applies to | What it does |
|---|---|---|
name | all | Display name — used in the guest's profile, the adventure log, notifications, and share pages. |
description | all | Longer copy shown on detail panels and share pages. There's an AI-assist button if you're drafting a lot of these. |
| Icon | all | PNG, JPEG, WebP, GIF or SVG, up to 4 MB. Upload it — the platform serves and caches it. |
maxValue | achievements | Auto-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. |
type | items | Free-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". |
rarity | items | One of common, uncommon, rare, epic, legendary. Drives the rarity treatment on share pages. |
| Stacks | items | On 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 model | items | A .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:
| Call | Repeat 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 item | Additive. Each call adds to the stack. There is no dedupe — call it twice and the player has two. |
awardItem(id) — non-stacking item | Idempotent. 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:
| Shape | How to get it | Use it for |
|---|---|---|
| Stacking | Default. Leave Stacks ticked. | Currency and consumables — coins, potions, ammo. One row, growing amount. |
| One-of-a-kind | Untick 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. |
| Unique | Call 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
- The guest gets a notification — on the first unlock only, and only for guests with a real (non-anonymous) account playing a production build.
- It lands in their adventure log for that visit, and may be lifted into the park's public activity feed when the visit ends.
- Achievements, badges and items all grant profile XP — 400 XP per completed achievement, 250 per badge, 100 per inventory entry (per entry, not per stack size). That feeds the guest's DreamPark level and rank title. Partial progress is worth nothing until the achievement completes, so a
maxValue: 10achievement pays out once, on the call that reaches 10 — and because completion is sticky, that XP never comes back off. - Nothing grants DreamPoints automatically. If you want a reward to pay out points, call
addDreamPointsyourself — see below.
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:
- Park-wide scripts on
Player.prefab. A system that grants a welcome item onstart()will hold that grant until the guest reaches one of your attractions. That's the intended behaviour — but if you were counting on it landing immediately, it won't. - Editor testing without a GameArea. Testing a lone prop in an empty scene means there's nothing to walk into, so editor sessions open the gate automatically. Set
ContentGate.AutoOpenInEditor = falseif you want to exercise the real device behaviour.
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 write | What 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 key | A score row carrying the latest value, without the personal-best wording. |
everything else — including every storage.min write | Folded 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:
- A star icon and a distinct accent colour in the timeline, so scores read as scores at a glance.
- Attraction preview art. Score rows and attraction discoveries are the only rows that carry an image tile — the auto-generated HD preview of the attraction the score was set in, alongside its real catalog name.
- They're shareable. A guest can mint a score row into a public share page with its own OG card and URL.
- Top billing in the park feed. When a visit ends, the platform distils it down to a handful of notable moments for the park's public activity feed. Score rows rank first — above achievements, badges, attraction discoveries and items. One per attraction makes the cut.
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:
| Surface | Personal best | Ordinary 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 page | Eyebrow New High Score, chip High Score | Eyebrow 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:
- Name your headline score
scoreorhigh_scoreso it renders without a qualifier, and write it withmax. - Keep score writes in attraction scope — that's the default, so just don't reach for
storage.gamehere. The attraction binding is what supplies the preview art and the real attraction name. A game-scope score renders without art and without the "in <attraction>" clause. - Write the score once, at the end of a run. Every
maxcall is a candidate row. Writing per frame or per point produces a wall of noise in the guest's timeline, and only one of those rows will survive into the park feed anyway.
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:
- Running on a headset, in the DreamPark app. The Unity editor — whether on an SDK preview key or otherwise — never moves real points.
- The attraction is approved. Experimental content can't award or spend.
- The guest has a claimed account. Anonymous guests can't hold DreamPoints at all.
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:
- A spend can be refused for insufficient balance. That's a real failure, not a no-op — so never hand over what they bought on the strength of the call returning. Read the balance back and confirm it moved.
- Awards are capped per grant and per guest per day. Past the cap you get the same successful no-op, so treat a granted point as something to verify rather than assume.
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.
| Behaviour | In editor (preview key) | In production (headset) |
|---|---|---|
| Game storage read/write | Yes | Yes |
| Profile reads | Yes | Yes |
| Award item / achievement / badge | Yes — tagged as a preview grant | Yes |
| Unlock notifications to the guest | No | Yes (first unlock only) |
addDreamPoints | Succeeds, changes nothing | Yes |
spendDreamPoints | Succeeds, changes nothing | Yes |
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.
| Property | Value |
|---|---|
| Peers per session | 16 (a Wi-Fi budget, not a hard limit — relay traffic scales with the square of the peer count) |
| Max message size | 16 KB |
| Rate limit | 60 messages per second, per peer |
| Time to form a session | ~3–5 seconds from cold start |
| Who can host | Quest / 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:
- Inside a spawned attraction, the hash walks up the hierarchy and stops at the attraction's spawn boundary, mixing in a key derived from park data that's identical on every client. Everything below that boundary comes from your prefab, so it matches everywhere. Don't reorder siblings inside a networked prefab between builds — sibling index is part of the hash, so shuffling children changes IDs.
- Objects placed at a scene root hash on their name only, because root ordering differs between the editor and device builds. Give scene-placed networked props unique names. Two objects called
Crateat the root will collide. (Clone)suffixes are stripped, so instantiation timing can't shift a hash.- If you need to pin an ID by hand — an object whose hierarchy genuinely differs between editor and device — set
explicitIdon the component to any non-zero number. It must be unique within the park and identical on every client, so set it on the shared prefab, not per-instance.
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:
| Warning | What 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 subscribers | The 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
- You don't receive your own messages. The relay sends to everyone except the sender. So apply the change locally as well as broadcasting it — the "optimistic local apply" pattern you'll see in every sample. This also means a solo guest still gets working gameplay with nobody else in the room.
- Routing is by Net ID, not by event type or by script. Every
onneton that object receives every event type sent to it. If one object handles several kinds of message, branch ont.typeyourself. - Delivery is reliable and ordered. Messages arrive, and they arrive in the order you sent them. There's no unreliable or unordered option, which means there's no fire-and-forget channel for high-frequency state — budget accordingly.
net_sendcan benil. It's wired up atawakeonly if the networking client already exists in the scene. Always guard, so solo play and misconfigured scenes degrade instead of erroring:
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:
- Add Component → NetId.
- Add a
LuaBehaviouron the same GameObject, pointed at your script. - Define
function onnet(payload)in that script, and callnet_sendwhere 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 provided | Do this instead |
|---|---|
| Networked spawn / despawn | Pre-place objects disabled; network the enable/disable. |
| Late-join state sync | Rebroadcast important state on a slow heartbeat. |
| Authority / ownership | Design for last-write-wins. |
| Join / leave events | Infer presence from your own heartbeat messages if you need it. |
| Unreliable or unordered channel | Throttle and interpolate rather than flooding. |
| Per-player identity on the wire | Put an ID you generate into your own payload. |
| Cross-venue or internet play | Sessions 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.
| Symptom | Usual cause |
|---|---|
Both devices log Hosting | They 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 beacon | Working 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 happens | Look 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 nil | Networking 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 load | You'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:
- Content ID — pick from a dropdown of the content folders in your project
- Name and description — the launch window shows these read-only, and an empty name blocks the upload
- Logo image
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:
- Patch — incremental upload of just the files that changed. Unchanged bundles are served from prior versions.
- Code only — Lua bundle plus catalog. Use when you've only changed Lua and want a fast OTA update. C# can't ship this way; compiled scripts live in the player binary.
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:
- Texture Optimizer — finds oversized or unnecessarily large textures and offers one-click downsampling
- Material Optimizer — audits your materials and converts them onto the DreamPark Universal / Unlit / Particles shaders, flagging orphans along the way
- Audio Optimizer — flags uncompressed or oversized audio
- Animation Optimizer — trims expensive animation data
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:
- Email community@dreampark.app for SDK and program questions.
- Use
DreamPark → Troubleshootingin the editor for common fixes. - See Revenue Share for economic terms.