Discord Sign In MyDreamPark
SDK v1.8.0

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 DreamPark headsets from there.

Public on GitHub

Clone github.com/Future-Circus/dreampark-sdk and open it in Unity 6. No approval gate to get started.

Lua-first scripting

XLua exposes Unity's API through CS.*, and scripts push over-the-air without rebuilding.

One-click publish

The editor's Content Uploader packages and ships your build to every DreamPark headset.

The SDK on GitHub
git clone https://github.com/Future-Circus/dreampark-sdk.git MyGame

Public, no approval needed. Run git lfs install once before you clone (see Requirements).

Best practices

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

Overview

DreamPark is a mixed-reality theme park platform. It turns real spaces into 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 space. 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 headset, runs there, and earns you a share of DreamPark's revenue, split by how much guests play it.

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 DreamPark: 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 space. 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 — https://github.com/Future-Circus/dreampark-sdk (clone command above). The repo is public. Run git lfs install first, then open the folder in Unity 6000.3.23f1 via Unity Hub → Add.
  2. Set your content ID. Once packages have resolved, a setup popup asks you to pick a unique identifier for your game (e.g. CoinCollector). This renames the starter folder under Assets/Content/ for you.
  3. Sign in: DreamPark → Sign In. Enter your email, then the 6-digit code we send you. There's no separate sign-up — the first code you verify creates the account.
  4. Open Assets/[StartHere].unity — it opens by itself the first time the project comes up, with the Meta camera rig, hand tracking, passthrough and occlusion already wired. Press Play — you're in the starter scene. (Want a different scene on launch? DreamPark → Startup Scene...)
  5. Create your attraction. Drop an AttractionTemplate, pick a size, drag in props. More below.
  6. Script gameplay in Lua with LuaBehaviour — the same hooks as a MonoBehaviour, and it releases instantly. More below.
  7. Test on a Quest. Put a Quest 3S or Quest 3 in Developer Mode, connect over Quest Link (USB-C data cable) or Air Link, accept the prompts in the headset, press Play, and check passthrough and hand tracking work.
  8. Publish. Open DreamPark → Content Uploader, fill in name/description/logo, click Upload Release, write release notes, then Start · All in the launch window. More below.
  9. Play it. Open the DreamPark iOS app (private TestFlight beta — email aidan@dreampark.app for an invite) and turn on Experimental Mode in your park settings. New uploads show up there right away.

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 guest's body. Head, hands, body and feet colliders, persisting across attractions — anything that belongs to this player lives here as LuaBehaviours. Title-wide rules go in the Game Manager (see Packages).
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 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 (A_TestAttraction and A_DreamSequence), 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 game. If you're signed in, the popup also checks that no other developer already owns the name.

If the popup comes back, the template folder YOUR_GAME_HERE is still on disk. Rename or delete it (see Troubleshooting).

Create your first Attraction

Open Assets/[StartHere].unity. The Meta camera rig, hand tracking, passthrough and occlusion are already wired up, along with your Player.prefab, a starter Attraction.prefab and a few [EXAMPLE] objects. Build here, and don't delete the building blocks.

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 can auto-generate the floor mesh for you, so you don't have to model it yourself unless you want to. (There's also a generateCeiling toggle in the inspector — it's legacy and does nothing now. Ceilings are handled globally by the player rig's depth-mask plane rather than per attraction, so you can ignore it.) When it's ready, save it as a prefab under Assets/Content/<your-id>/Prefabs/ — a transparent preview tile is generated automatically for every attraction, so it shows up in the app with tile art. To refresh one after you change the prefab, run DreamPark → Troubleshooting → Regenerate Level Previews. Open A_TestAttraction under Assets/Content/Sample/Prefabs/ to see a working example.

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 calibration Y-offset so the prop sits correctly on the floor in real spaces.

Leave PropTemplate.category alone. It's a frozen field, kept only because it's already baked into the address of every prop ever shipped — and that address is the key the backend joins on for downloads, views and your revenue. Changing a prop's category doesn't re-tag it; it renames the asset, and every row of that prop's history stays behind under the old name. New props stay on the default.

The category a guest actually sees isn't this field at all — you set it per attraction in the developer portal's Attractions tab after uploading, against a list that we can extend without anyone touching a prefab.

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. Preview tiles are generated for props too.

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

How your content gets addressed

You never assign addressable addresses or labels — the SDK stamps them from your prefabs every time it processes your content folder, and hand-editing them in the Addressables window will be overwritten. It's worth knowing the shape, though, because it explains one confusing failure:

What it isAddress it gets
An attraction{contentId}/Levels/{size}/{name}
A prop{contentId}/Props/{category}/{name}
Models, audio, textures…{contentId}/{Type}/{name}

The backend classifies your content by that address, not by the file name. Anything with a Levels/ address is an attraction; anything with a Props/ address is a prop. The A_ and P_ prefixes are still the convention and still worth following for your own sanity, but they aren't what's checked.

So if an attraction doesn't show up in the Attractions browser after a publish, the thing to check is the component on the prefab root — an AttractionTemplate is what produces a Levels/ address in the first place. Renaming the file won't fix it.

Never name a new attraction L_*. A leading L_ marks a pre-attraction Legacy Level, which is hidden from the Attractions browser.

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 belong to the guest and should outlive a single attraction — hand-held tools, damage effects, per-player audio — attached as LuaBehaviours. Title-wide rules, progression and shared state go in your Game Manager when your title runs as a package. How the rig is built and loaded: The Player rig.

Next SDK Putting a blaster, wand, sword or phone in the guest's hand? Read Hand-held objects first — the hand's axes on a headset aren't what the Editor used to show, and dp.hold does the mounting for you.

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.

Hand-held objects New in the next SDK release

Blasters, wands, swords, torches, phones, balls — anything a guest holds. This is the one part of a DreamPark game where the Editor and the headset used to disagree: a prop hung under the hand with no rotation looked right in Play mode and came out rolled 90° on a Quest, top pointing out of the back of the hand and handle sticking out of the palm. Three shipped games hit it. The fix is a convention for authoring the prop and one Lua call that mounts it.

New in the next SDK release

dp.hold, dp.hold_two_handed, dp.muzzle, dp.release_hold and dp.editor_hand_frame aren't in a released SDK or DreamPark app yet, and names can still change before release. The prop convention below works today. Until the release, and for any title that must still run on an older app, mount props with the Lua helpers that ship beside it in Assets/DreamPark/Samples/Handheld/: handheld_mount.lua.txt (follows the hand in use) and two_hand_aim.lua.txt (two-handed props). They implement the same contract. To branch at runtime: if dp.hold then … else … end.

The hand frame on the headset

Your rig's hand nodes (HandTracker on LeftHand / RightHand / BothHands, and the RemoteRig hands) follow the OpenXR wrist joint. Its axes are the same for both hands, which is exactly why one rotation can't be right for both:

Wrist axisRight handLeft hand
+Zwrist → fingers (where you point)wrist → fingers
+Yback of the hand (palm faces −Y)back of the hand (palm faces −Y)
+Xpinky side — the thumb is −Xthumb side — the thumb is +X

The origin is the wrist. A prop lands on one of two targets, in metres: the fist centre (the power-grip axis under the middle finger) at (−0.007, −0.033, 0.081) right / (+0.007, −0.033, 0.081) left, or the palm surface at (0, −0.022, 0.064) for both hands. DreamPark is hand-tracking only — never design a prop around controllers, whose anchor has different axes.

Author the prop: Grip, Muzzle and friends

prefabMyBlaster       root: the object. No hand rotation baked in, scale 1
├── Model       the art. Import-axis fixes (rotation, scale, offset) live HERE
├── Grip        lands on the fist centre. +Z = where the fingers point, +Y = the thumb side
├── PalmGrip    instead of Grip, for things resting on the open palm (phone, tray, ball)
├── GripLeft    optional: what the left hand uses instead (PalmGripLeft for palm props)
├── Grip2       optional: where the off hand holds a two-handed prop
└── Muzzle      barrel tip or emitter. +Z = the shot direction (skip it if nothing shoots)

Archetypes: the hold is data in the Grip

Grip rotations are Unity Euler angles for the right hand; positions are in the prop's normalised frame (+Z forward, +Y up). Device-proven holds were approved on a Quest; derived ones were computed from Meta's hand data and haven't been confirmed on a device yet — render them and try them.

ArchetypeExamplesAuthor itGripLeft hand
Pistol
pistol
blaster, confetti gun, spray can, drill+Z barrel, +Y top of the gunHandle point P (1.5 cm under where the handle meets the body) + (−0.033, −0.048, +0.012), rotation identity — the fist rides the lower handle and the gun sits high. Device-proven: LaserTag.GripLeft = P + (+0.033, −0.048, +0.012)
Wand
wand
magic wand, baton, pointer, light stick+Z toward the tip, +Y the decorated top, butt at the backButt + (−0.011, +0.001, +0.058), rotation (24.2, 9.5, 3.9) — the shaft leans ~25° toward the thumb, held like a pencil rather than straight out. Device-proven: Wizard's Way.Same Grip
Sword
sword
sword, dagger, knife, lightsaber+Z the edge, +Y the blade tipOn the hilt axis, 2.5 cm under the guard; rotation (−13.9, 0, −5.7) — the blade comes out of the thumb side, raked 14° toward the fingers. Derived.GripLeft (−13.9, 0, +5.7)
Axe, hammer, bat
haft
axe, mallet, bat, club, golf club+Z the striking face, +Y the headOn the haft axis, 5.5 cm above the bottom end; rotation as sword. Derived.As sword
Torch / handle
torch
flashlight, microphone, bottle, ice-cream cone+Z front, +Y the topOn the handle axis at 40% of its height; rotation as sword. Muzzle at the top. Derived (Meta's own torch).As sword
Flat on the palm
palm
phone, tray, plate, card+Z toward the fingertips (top edge), +Y the face away from the palm (the screen)PalmGrip on the back face; rotation (0, 0, −90). Derived.PalmGripLeft (0, 0, +90)
Ball
ball
ball, apple, orb, snowballanyPalmGrip at the contact point on the prop's +X side, identity. Derived.PalmGripLeft on the −X side
Two-handedrifle with a foregrip, staff, steering wheel, guitaras the main archetypeGrip per its archetype, plus Grip2 where the off hand holds it (a foregrip uses the pistol axes). Mount with dp.hold_two_handed.As its archetype

Hold it from Lua

lua-- blaster.lua.txt, on the blaster prop
-- @var model transform

function start()
    dp.hold(self.gameObject, 'active')   -- whichever hand is in use; re-mounts when that changes
end

function fire()
    local origin, dir = dp.muzzle(self.gameObject)   -- read it on every shot, never cache it
    spawn_bolt(origin, dir)
    kick(model)                                      -- recoil goes on Model, never on the root
end
CallDoes
dp.hold(prop, side[, hand_node])Mounts the prop by its Grip for that side. side is 'right', 'left' or 'active' (the default — follows the hand in use and re-mounts the moment it changes). The hand node is the one you pass, else the nearest hand node above the prop, else your rig's hand for that side, and the prop is reparented under it — so it doubles as "pick it up". Honours HandTracker.flipVisual. On a rig that only has a BothHands tracker, 'left' / 'right' mount on that hand (a follow node under your rig tracks dp.hand(side))Next SDK rather than on whichever hand is active. Calling it again just updates the side. Returns false, with a console line saying why, when there's no hand to hold it.
dp.hold_two_handed(prop, side, off_hand[, hand_node])Holds it in the main hand and, while the off hand is tracked, pivots it about the main fist so Grip → Grip2 points at the off fist, keeping the main hand's roll. When the off hand drops out of tracking it goes back to the one-hand pose. Use the per-side nodes (Player/LeftHand), not BothHands.
dp.muzzle(prop)pos, fwd — the Muzzle's world position and its +Z (the prop root's, if it has no Muzzle). Aim with this, never with the hand's or the camera's forward.
dp.release_hold(prop)Stops managing it. It stays where it is.
dp.editor_hand_frame()'device' — the Editor's hands match a headset (below).

The Editor now matches the headset

The Scene-view Simulator and the AI Playtester pose both hands in the device wrist frame — a relaxed pistol grip pointing where you look — so a correct mount looks correct in Play mode. Older SDKs copied the head's rotation onto both hands, so a correct mount looked rolled 90° and a "fixed" one was wrong on the headset. If your Lua compensated for that, remove the compensation, or gate it so it still runs on an older SDK:

lualocal old_editor_hands = dp.editor_hand_frame == nil or dp.editor_hand_frame() ~= 'device'

Play mode is still only a first look. The render and a device test below are the acceptance test.

HandheldKit

An editor tool for getting any imported model — FBX, GLB, Blender, Meshy, Asset Store — into the convention. It's a builder, Tools/Builders/Handheld/HandheldKit.cs, run through the Unity CLI (Tools/agent/u.sh run_script --file Tools/Builders/Handheld/HandheldKit.cs --entry HandheldKit.<Fn> --args '[…]'):

EntryDoes
Inspect [model, outDir]Renders the raw model from six sides with axis rods (+X red, +Y green, +Z blue). Look, and write down which axis is forward (barrel, blade edge, phone top) and which is up (gun top, blade tip, screen).
Detect [model]Prints the bounds and, for gun-shaped models only, a guess at forward and up. A guess — check it against Inspect.
Normalize [model, out.prefab, forward, up, opts]Wraps the art in Model, turns it to +Z forward / +Y up, fixes the scale, places Grip / PalmGrip (and PalmGripLeft) and Muzzle for the archetype, recentres on the Grip, saves and validates. Opts, separated by ;: archetype=pistol|wand|sword|haft|torch|palm|ball, scale=auto|<factor>|len:<m>, grip=auto|x,y,z, muzzle=auto|art|none|x,y,z, recenter=grip|none.
Render [prop.prefab, both, outDir]Mounts the prop on Meta's hand — a power grip for Grip, an open hand for PalmGrip — in both hands. Tune with grip= or by moving the Grip child, then render again.
Validate / ValidateOpts [prop.prefab, opts]Checks one prop: markers present and unscaled, no negative scale, under 1.5 m, Grip inside the mesh and behind the Muzzle, Muzzle at the tip, not upside down. muzzle=none for props that don't shoot.
ValidateRig [Player.prefab]Checks every prop under every hand, local and remote: each within 10° and 2 cm of the device pose for its side, remote copies within 5 mm / 2° of the local ones, nothing hung on an old template pivot.
Place / MountThe same maths for your own builders, so a prefab already looks right before Play.

Check it — both hands, every time

  1. HandheldKit.Validate passes on the prop.
  2. HandheldKit.Render with both — and look at every view. Gun: the fist rides the lower handle, body above the knuckles. Wand: the shaft runs out between thumb and index, leaning toward the thumb. Sword or axe: the handle goes through the fist, the blade or head sits above the thumb. Palm: the face is visible from the palm side.
  3. HandheldKit.ValidateRig passes on your Player.prefab.
  4. The Hand-held props pre-upload check (handheld-convention) comes back clean, or each finding is explained.
  5. Play mode: no Lua errors, the right prop showing in the right hand, the Muzzle firing forward.
  6. On a Quest: both hands, pointing at something, with a second player watching you. The bar is "it feels like it's in my hand".
MistakeOn the headset
Identity rotation under the handRolled 90°: top out of the back of the hand, handle out of the palm. Use dp.hold.
One rotation for both handsRight hand fine, left hand upside down.
Axis fixes on the prop root or in the FBX import settingsEvery later mount inherits the error. Fix them on Model.
Grip at the bounding-box centreThe prop floats above or sinks into the fist. Use the archetype's Grip.
A palm prop with no PalmGripLeftThe phone screen faces into the left palm.
Aiming with the hand's forwardShots leave from the wrist at an angle to the barrel. Use dp.muzzle.
A visual prop on a jointed HandTouch bodyIt lags the hand and gets pushed by the world. Visuals under the tracker; colliders on HandTouch.

The full reference — the measurements, flipVisual setups, and what each hold was proven on — is HANDHELD.md at the root of the SDK repo.

Scripting in Lua

Write your gameplay in Lua. All of it. The SDK is built around XLua, and the gameplay you'd write in a MonoBehaviour you can write 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

Declare inspector fields with a comment at the top of the script: -- @var name type [default]. Types: gameobject, transform, component, script, audioclip, material, sprite, texture, vector3, color, float, int, bool, string and list (a list of GameObjects). Defaults apply to numbers, bools and strings; everything else is set in the Inspector. An injected script arrives as that script's Lua scope, so you call its functions directly. Picking a script auto-adds its fields, and ↻ Sync from Lua rebuilds the list while keeping existing values.

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 shipped Player.prefab's colliders are Untagged on the Default layer, so that test misses the guest entirely. There is also no Hand tag at all. In oncollisionenter(collision), pass collision.collider — a Collision itself always returns false. 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#.

The dp API

dp is a global table in every Lua script — nothing to require, nothing to find. It exists so that the handful of things every attraction needs (where is the guest, which attraction am I in, who else is here) don't each get hand-rolled into a slightly different pcall around CS.*.

Three of its branches have their own sections: dp.profile, dp.storage (and each script's own storage), and dp.score for leaderboards. The rest is here.

The guest

CallReturns
dp.is_player(x)true if a Collider or GameObject is the guest. Takes either.
dp.player()The player root GameObject, or nil before the rig exists.
dp.head()The guest's head Transform (world space), or nil.
dp.hand(side)A hand anchor — "left", "right", or omit for whichever hand is currently active. nil before the rig exists.
dp.hands()Both, as left, right.

dp.is_player is the one to use rather than a tag or layer test — Scripting in Lua explains why the hand-written version is wrong against our own rig.

Finding your way around

Reaching another script shouldn't require knowing its C# component type. These walk the hierarchy for you, and reading a scope boots that script if it hasn't run yet — so a cross-script reference never depends on Awake ordering.

CallReturns
dp.scope(go)Another object's Lua scope, so you can call its functions and read its variables. Unlike a hand-rolled GetComponent, this finds a scope across all the LuaBehaviours on that object, not just the first.
dp.attraction(go)The scope of the attraction this object sits inside.
dp.attraction_root(go)That attraction or prop's root GameObject.
dp.game_id(go)The content ID this object belongs to, or nil.
dp.on_global(name, fn)Calls fn(value) as soon as a global with that name exists — straight away if it already does. The way to reach a manager script without scanning for it.

Session & players

Full detail in Players in the park and Coordinates & the park frame; this is the index.

CallReturns
dp.relay()Connection detail: present, connected, state, ping, sent, received, send_rate, queued, cap, budget. dp.relay().connected is cheap enough to poll every frame.
dp.on_connected(fn)Runs fn once, as soon as the relay link is up (straight away if it already is). Use it for anything whose timing matters, like a join handshake.
dp.session()Session detail: present, state (Idle, Searching, ClientDreamBox, ClientPeer, Hosting, Reelection), host, is_host, peers (a count, only while you host — use dp.peers() for the roster), park.
dp.me(), dp.peers(), dp.peer(id)Your id, the other players, one player.
dp.set_state(t)Publish a small table about yourself. Resent to late joiners.
dp.on_peer_join(fn, owner), dp.on_peer_leave(fn, owner)Presence edges. dp.off_peer_join / dp.off_peer_leave unsubscribe.
dp.park(), dp.to_park(v), dp.from_park(v), …_dir, …_rot, …_yawThe shared park frame. Anything crossing the relay goes through these.
dp.pack(t), dp.unpack(t)Lua table ↔ typed-wire JSON, for code that uses onnet and builds its own JSON.
dp.dir(v)Marks a Vector3 as a direction rather than a point, for the typed wire.
dp.leader(), dp.is_leader()
dp.on_leader_changed(fn)
dp.rank([id])Next SDK
Who decides for your title, and everyone's join order. Leader, clock & randomness.
dp.clock(), dp.clock_synced()
dp.random(key[, a, b])Next SDK
One timeline and the same dice on every headset.
dp.shared_state(name)Next SDKReplicated encounter state with one writer and automatic late join. Shared state.
dp.net_body(go[, opts])Next SDKMakes a Rigidbody a shared physics prop. Physics props.
dp.to_attraction(v)
dp.from_attraction(v), …_dirNext SDK
Positions relative to your attraction, for hand-packed messages. Attraction-relative positions.
dp.budget()Next SDKThis headset's send budget and how it's split. The send budget.

Hand-held props New in the next SDK release

CallReturns / does
dp.hold(prop, side[, hand_node])Mounts a prop in a hand by its Grip — 'right', 'left' or 'active'.
dp.hold_two_handed(prop, side, off_hand[, hand_node])Same, aimed at the off hand through Grip2.
dp.muzzle(prop)pos, fwd of its Muzzle, in world space.
dp.release_hold(prop)Lets go.
dp.editor_hand_frame()'device': the Editor's hands match a headset.

All of it in Hand-held objects. On an app older than the release these new functions are nil; test for them (if dp.shared_state then) when a title must run on both.

Budget against budget, not cap. dp.relay() reports two numbers on purpose. cap is what the current host enforces and it moves — a DreamBox kiosk doesn't rate limit at all. budget (60) is the floor, whoever is hosting. A session can lose its kiosk mid-play and a headset takes over, and anything designed against kiosk headroom breaks at exactly that moment.

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, and DreamPark → Relocate ALL stray packages to ThirdPartyLocal sweeps every vendor folder that landed at the root of Assets/. 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 — looks for anything in your project that references a file in your content folder's ThirdPartyLocal/, follows those references and their dependencies, and moves them into Assets/Content/<your-id>/ThirdParty/. If a referenced script belongs to a package's assembly, it brings the rest of that assembly's scripts too, so the code still compiles. It preserves Unity GUIDs so prefab and scene references don't break. The same sync runs automatically every time you upload, and the window has an Auto-sync on Play toggle, so you can let it handle the move 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.

Materials & shaders

DreamPark is mixed reality. Every pixel of virtual geometry has to be clipped by the guest's real room — their hands, their furniture, the walls — using Meta's Depth API. That integration lives in the shader, which makes shader choice a correctness requirement rather than an art preference.

Use one of these three for every material you ship:

Use forShader
Anything lit — props, environment, charactersShader Graphs/DreamPark-UniversalShader
Anything unlit, flat, emissive, or UI-in-worldShader Graphs/DreamPark-Unlit
Every ParticleSystem materialDreamPark/Particles

Never ship Standard, URP-Lit, URP-Unlit, or a marketplace or asset-pack shader. They compile fine and they look fine in the Editor. The failure only appears on a headset, in passthrough, where the object refuses to be occluded and draws straight over the guest's hands and furniture.

Opaque materials must have Alpha Clipping on

This is not a default and not a preference — it's enforced, and it's the one that catches everybody.

The occlusion subgraph drives its result into the fragment's alpha. A Transparent surface consumes that alpha through its blend, so occlusion just works. An Opaque surface has nothing downstream that reads alpha — the clip is the only consumer. With Alpha Clipping off, the occlusion value is computed, written, and thrown away, and the material renders at full opacity over the real world.

This is the failure that survives every other check. The shader is correct, so the occlusion check passes. The Editor looks perfect. Play mode looks perfect. It is wrong only on a headset, only in passthrough, and only after you've uploaded.

There are also three pieces of state, and any of them can disagree. _AlphaClip is the float the inspector draws; _ALPHATEST_ON is the keyword that actually selects the clipping variant; _alphaClipping is the threshold, and at 0 it clips nothing, so clipping can be "on" in both of the others and still throw occlusion away. Assigning a shader to an existing material carries over same-named floats and the old keyword set, so a converted material routinely lands with one on and the other off — including the case where the toggle reads ON in the inspector while the shader isn't clipping at all. Never test just one: DreamParkMaterialRules.IsSatisfied(mat) checks all three, and it's what the upload gate uses.

Two smaller things worth knowing:

You mostly won't have to do this by hand

Several layers keep the rule true, so the usual experience is that it's simply already correct:

One thing the automatic repair can't fix: an albedo texture whose alpha channel is empty or garbage — some asset packs ship exactly that. Enabling clipping makes the object vanish in the Scene view. That isn't the fix misbehaving; it means the material could never have been occluded and the texture needs fixing. Always look at the Scene view after a bulk conversion.

TextMesh Pro text Next SDK

TextMesh Pro's own shaders have no occlusion, so world-space text used to draw over the guest's hands. The SDK now ships occluded twins — DreamPark/TextMeshPro/Distance Field and DreamPark/TextMeshPro/Mobile/Distance Field — with the same properties and inspector, which render identically off-headset. The SDK's default fonts already use them. A font material of yours on a stock TMP shader shows up as a Materials without Meta Occlusion warning with a one-click fix, or fix them all with DreamPark → Troubleshooting → Use Occluded TextMesh Pro Shaders. On the new app, world-space text inside your attractions is also moved onto the occluded shader when it loads; HUD text on your Player rig and screen-space UI are left alone.

Scrolling textures, transparency and what the checks scan Next SDK

Packages: Arena, Sequence & Adventure

Every play space is a different shape. You don't ship a park — you ship packages: three ways the app can turn your title into something playable in whatever space an operator scanned, automatically, without losing what you authored (the order things happen in, where the game starts and ends, which pieces it can't live without).

All three are authored in one place — DreamPark → Content Uploader → Packages — from the same library of Attractions and Props, and all three share your Game Manager and your Player.

ArenaSequenceAdventure
In one lineThe best-fitting piece of your content for every free spaceYour whole game in one roomYour game spread across the space
SpaceAny scanned rectangle, down to 1 × 1 ftOne room, authored at 12 × 18 ft, shrinks and grows to fitThe whole scanned park
The guest seesOne representative Attraction or Prop per free spaceStart Level → your attractions, one at a time → Game Over LevelStart Point → your attractions along a walking route → End Point
You authorPriority within each size — everything's included by defaultThe order, plus editable Start, Overlay and Game Over levelsStart Point, End Point, ordered attractions and Groups
If it can't fitNothing is placedNothing is placedFalls back to your Sequence, then your Arena
Required?NoYes — needed to uploadNo

Arena and Adventure are optional; every title needs a valid Sequence to upload. An Adventure you haven't finished (no Start Point or no End Point) is simply left out of the upload — never shipped broken, and never in your way.

Why packages matter

The Packages panel

The Content Uploader has a Park Assets section — your reusable library of every Attraction and Prop, where you can also create Groups of reusable sets — and a Packages section with three tabs: Arena · Sequence · Adventure. Each tab shows a GAME MANAGER card, a PLAYER card, the organizer for that format and a ▶ Test … in Park Simulator button.

Placing something in a package doesn't move it — a placement is a reference, so the same asset or Group can appear more than once. Your choices are saved as small .dreampark-*.json files in your content folder; commit them. (For historical reasons .dreampark-sequence.json holds the Adventure and .dreampark-dream-sequence.json holds the Sequence — don't rename them.) Testing or uploading compiles the packages into a runtime recipe for you (and bakes any attraction's shrink/grow variants that need it) — there's nothing to generate by hand, and no "Dream Sequence" prefab to maintain.

Arena

Arena is the instant package. Every Attraction and Prop is automatically sorted into a size: its smallest footprint, in whole feet, short side × long side. When the app builds an Arena it goes through the scanned floor one free rectangle at a time, takes the largest size that fits, and places that size's first entry. Big spaces get your attractions; the gaps left over get your small props.

Arena entries are alternatives, so none of them is ever "required". If an entry isn't available on the device, the app moves to the next one in that size. If no size fits anywhere, nothing is placed. The first card is the default pick, not a guarantee — an operator can re-roll a space to deal it a different entry. Only AttractionTemplates and Props with an authored footprint are candidates, and the Sequence's Start, Overlay and Game Over levels never are.

Sequence

The Sequence is your whole game, playable in one room. The room is authored at 12 × 18 ft and shrinks or grows within the limits of the levels in it. The guest starts on your Start Level; your attractions then stream in one at a time, in the same room, with a particle transition covering every swap, until your Game Over Level. A persistent Overlay Level sits over every ordinary level for navigation and HUD.

Every title needs a Sequence. The Sequence package pre-upload check blocks the upload until it has at least one attraction that fits the 12 × 18 ft room and its parts are in place — its fix button rebuilds it for you. It's also what an Adventure falls back to first.

The Sequence tab shows, top to bottom: the Game Manager and Player; the START LEVEL and OVERLAY LEVEL; your attractions and Groups in order (drag to reorder, drop from Park Assets to add); and the GAME OVER LEVEL.

Levels are numbered slots

SlotWhat plays
1Start Level
2 … N+1Your attractions, in organizer order (Groups flattened in place)
N+2 = dp.level_count()Game Over Level

What can play in a Sequence

An Attraction plays if its authored size — or its smallest (shrink) size — fits 12 × 18 ft, either way round. Anything that doesn't is shown dimmed and skipped. Props never play as Sequence levels. An attraction marked Game Requires Attraction that can't fit blocks the upload rather than silently disappearing.

How the room is sized

Transitions

A level change plays the transition for at least 2.5 s and swaps the level at the end of it, once the next level has downloaded and been checked for fit. For the raw dp.load_level the newest request wins; the default Game Manager's load_level instead refuses a new request while one is still in progress. If the next level can't load or can't fit, the guest stays where they are and dp.on_level_failed fires.

Editing Start, Overlay & Game Over

The first time you open the Sequence tab, the SDK generates three prefabs in Assets/Content/<your-id>/DreamSequence/: Start Level, Overlay Level and Game Over Level. They're yours. Click a card to find the prefab, open it in Prefab Mode and edit it like any attraction. Each is an AttractionTemplate set to Custom 12 × 18 ft, with a Sequence Floor Border that follows the room's edge as it packs.

Start Level — your title screen

It ships with a START button on the floor at the centre of the room. Stepping on it plays a press animation and a click, ignores re-presses for about two seconds, and calls your Game Manager's start_game() — by default, the first attraction.

lua-- my-start-portal.lua.txt
local armed = true
function ontriggerenter(other)
    if not armed or not dp.is_player(other) then return end
    armed = false
    local gm = dp.game_manager()
    if gm ~= nil and gm.start_game ~= nil then gm.start_game() end
end

To change what "start" means — jump to a hub, pick a random first level — edit start_game in the Game Manager, not the button.

Overlay Level — navigation & HUD

It ships with elevator-style BACK and FORWARD buttons on the room's left edge (they call the Game Manager's previous_level() and next_level()) and the Default Level Transition effect. The Overlay is visible over your attractions and hidden on the Start and Game Over levels; it has no floor of its own and sits on whatever level is showing.

Keep the name Default Level Transition. The runtime finds the transition by that name and lifts it out of the Overlay so it can also cover the Start and Game Over swaps. Restyle it or swap in your own particle effect (a Unity ParticleSystem on the DreamPark/Particles shader), but if you rename it, every transition silently disappears.

Game Over Level — your ending

It ships empty, and an empty Game Over never forces a bigger room. Add a scorecard, credits, a thank-you, and a way to play again:

lua-- replay-button.lua.txt (a trigger on the Game Over Level)
function ontriggerenter(other)
    if dp.is_player(other) and dp.button_press() then
        local gm = dp.game_manager()
        if gm ~= nil and gm.load_level ~= nil then gm.load_level(1) end  -- back to Start
    end
end

Rules for all three

Adventure

An Adventure spreads your game across the space. You set a clear Start Point, the attractions and Groups in the order the guest should meet them, and a required End Point. The app lays them out along a walking route through the scanned park — Start first, End last, each attraction facing the one before it — and everything stays deployed; the guest walks between them.

The Game Manager

The Game Manager is your game's brain: one persistent prefab that holds your title's rules, progression and state, shared by Arena, Sequence and Adventure. It isn't a spatial container — nothing is parented under it and it never affects room size.

FileWhat it is
Prefabs/Game Container.prefabThe Game Manager (the file keeps its original name — don't rename it)
Scripts/game-container.lua.txtIts script — yours to rewrite

The Content Uploader creates both once and never overwrites them. The prefab carries a LuaBehaviour, a NetId (so every player in a session shares navigation) and OptimizedAFIgnore (so it's never culled for distance).

What the default script does

FunctionDefault
start_game()load_level(2) — the first attraction
next_level() / previous_level()One slot forward / back
load_level(index[, groupId])The shared, networked request. Slots are 1-based; with a Group ID the index is 0-based inside that Group. Returns false if a level change is already in progress or the slot is out of range.

It keeps every player on the same level by syncing through its NetId. That's why the buttons call the Game Manager's load_level: the raw dp.load_level is local to one headset.

Customizing it

Rewrite those functions however you like; buttons and portals don't care how you decide. A random order with no repeats:

lualocal played = {}
function next_level()
    local pool = {}
    for slot = 2, dp.level_count() - 1 do          -- attractions only
        if not played[slot] then pool[#pool + 1] = slot end
    end
    if #pool == 0 then return load_level(dp.level_count()) end  -- Game Over
    local pick = pool[math.random(#pool)]
    if load_level(pick) then played[pick] = true; return true end
    return false                                 -- busy; try again later
end

A hub built from Groups:

luafunction enter_world(name)
    for _, g in ipairs(dp.groups()) do          -- { id, name, sourceId, count }
        if g.name == name then return load_level(0, g.id) end
    end
    return false
end

Keep run state — score, flags, which worlds are cleared — here, where it survives level changes. The manager isn't inside an attraction, so use game-scope storage explicitly: dp.storage.game('YourGame').max('high_score', score). Attractions reach it with dp.game_manager().

Game Manager, Player or attraction?

Put it on…When it's…
Game ManagerTitle-wide rules and shared state: progression, routing, run score, randomizers, hubs, quest flags.
PlayerAbout this guest's body and senses: hand-held tools, damage screens, per-guest audio, what others see of you. Also anything that must work when your attractions are placed on their own.
AttractionEverything that belongs to one experience.

The Player rig

Player.prefab is the guest's body in your game. A PlayerRig on the root, and tracked children:

ChildWhat it does
HeadHeadTracker — follows the headset, with a collider
BodyBodyTracker — a torso collider below the head
FeetFeetTracker — a foot collider that can kick things
LeftHand, RightHand, BothHandsHandTracker — hand-tracked colliders; attach blasters and tools to the Handheld Pivot childrenNext SDK — or mount them with dp.hold, and check older pivots: Hand-held objects
RemoteRig (optional)What other players see of you — see Players in the park

Package API reference

These live on dp in every script and always answer for the package that script belongs to — there's no global "current package", because a park can hold several titles.

CallReturns / does
dp.package()The package's root GameObject, or nil
dp.game_manager()The Game Manager's Lua scope, or nil — call its functions
dp.game_manager_object()The Game Manager GameObject
dp.container(), dp.container_object()Older names for the two above; still work
dp.is_arena(), dp.is_sequence(), dp.is_adventure()Which format is running
dp.level_count([groupId])Number of slots, or of levels in a Group
dp.current_level_index()The current 1-based slot
dp.levels([groupId])Display names, in order
dp.groups()Sequence Groups: { id, name, sourceId, count }
dp.level_slot(index, groupId)0-based index in a Group → slot
dp.load_level(slot[, groupId])Raw, local level request. Prefer the Game Manager's load_level.
dp.preload_level(slot[, groupId]), dp.level_ready(slot[, groupId])Sequence attraction slots only (not Start or Game Over): download early / check it's ready
dp.level_loading(), dp.level_error()A change is in progress / why the last one failed
dp.on_level_loaded(fn), dp.off_level_loaded(fn)fn(slot) when a level finishes loading. In a Sequence that is one drawn frame after the new level appears (its floor is built under the transition first).
dp.on_level_presented(fn), dp.off_level_presented(fn)Next SDKfn(slot) in the same frame the new level becomes visible, before it is drawn — for anything that must change before the guest ever sees the new level (hide an exit effect, move a prop). Arena/Adventure call it just before on_level_loaded.
dp.on_level_failed(fn), dp.off_level_failed(fn)fn(slot) when it can't
dp.show_overlay(bool), dp.overlay_auto_visibility(bool)Control the Sequence Overlay. While auto-visibility is on, the next level change overrides show_overlay, so turn it off first.
dp.next_level(), dp.previous_level()Ask the Game Manager to move on / back
dp.button_press()For control scripts: plays the press feedback and returns false during its cooldown; on an object without button feedback it just returns true

Unsubscribe what you subscribe — the package holds on to your function:

lualocal function on_loaded(slot) print('now playing ' .. tostring(dp.levels()[slot])) end
function onready()   dp.on_level_loaded(on_loaded) end
function ondestroy() dp.off_level_loaded(on_loaded) end

dp.level_error() is one of invalid-or-inactive-slot, download-or-load-failed, room-fit-failed, spawn-failed, presentation-failed, missing-loader, load-exception or spawn-exception, and nil before any failure (each new valid request resets it).

Outside a package, dp.level_count() and dp.current_level_index() return 0, dp.levels() and dp.groups() return nil, dp.load_level returns false and every dp.is_*() is false. In an Arena or Adventure, dp.on_level_loaded fires immediately, and a groupId can be the placed Group's id or, when unambiguous, its library sourceId.

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)

storage.setProgress(62)             -- one overall "how far along" number per title (0–100 by convention)
storage.getProgress(0)
storage.gameId, storage.attraction  -- the scope this proxy resolved to

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. For a guest who never pairs, onReady never fires — there's no server data to wait for — so use it to refresh a display, never to gate the start of a round.

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. Matches the catalog ID or an instance ID, so hasItem("relic") is true for any unique relic.
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 }.
dp.profile.getHeightInches() / getHeightMeters()The guest's physical height (24–96 in). Returns the 5 ft 8 in reference (68 in / 1.7272 m) until a profile with a height loads.
dp.profile.getHeightFactor()Guest height ÷ 68. Multiply an authored height above the floor by it to put controls within reach. 1.0 when unknown.
dp.profile.onHeightChanged(fn)Calls fn(inches, factor) when the guest changes their height in the mobile app. Returns an unsubscribe function. The initial load doesn't fire it, so apply the baseline in onReady and subscribe there.

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

Height-aware ergonomics. Author reachable controls and eye-level targets for the 68-inch reference, then scale them per guest: targetY = floorY + (authoredY - floorY) * dp.profile.getHeightFactor(). Height is a local presentation value, so every player sees the same networked object at their own reachable height without moving it for anyone else. Drop-in sample: Assets/DreamPark/Samples/ProfileAPI/height.lua.txt.

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.

Item and achievement IDs are 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. Badge IDs are scoped to your game, so a short champion is fine, though namespacing doesn't hurt.

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 player walking around a DreamPark space 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, and it lasts until the guest's identity changes. Save data waits for one of your attractions specifically; profile awards wait for the first attraction of any game. At most 64 writes are held per game and the oldest are dropped past that, so a loop that writes before entry loses data.

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 DreamPark play session 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. Repeated writes in one visit fold to one row per key; a later write in the same visit adds a row only if it's higher.
storage.increment or storage.set on a score-like key (numeric values only)A 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

Rows from a max write are flagged as a personal best and worded that way everywhere; score rows from increment or set aren't:

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 row always outranks a plain score row in the feed — even one with a higher number. Another reason to write your headline score with max.

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. max gives you a reliable per-player personal best, and the park activity feed is where scores become social. To rank players against each other, use dp.score.

Leaderboards

storage.max keeps one player's personal best. To rank players against each other, use dp.score. It's a separate, global namespace, and every call is a network round trip — nothing is cached, because someone else's submit can change the board at any moment. Results come back in a callback as (ok, result).

lua-- At the end of a run: report the value; the server decides if it's a best.
dp.score.submit(storage.gameId, "high_score", finalScore, storage.attraction, "desc",
    function(ok, r) if ok and r.global and r.global.improved then celebrate() end end)

-- Top 10 at this park, anyone.
dp.score.fetchLeaderboard(storage.gameId, "high_score",
    { attraction = storage.attraction, thisParkOnly = true, limit = 10 },
    function(ok, board)
        if not ok then return end
        for _, e in ipairs(board.entries) do
            print(e.rank, e.displayName or "Guest", e.score, e.isMe)
        end
    end)

-- This player's own best (no board scan).
dp.score.fetchMine(storage.gameId, "high_score", { attraction = storage.attraction },
    function(ok, mine) if ok and mine.hasScore then print(mine.score) end end)

storage.gameId and storage.attraction are the scope the injected storage resolved to, so you don't hard-code either.

For a live scoreboard among guests in the same room right now, build it on the multiplayer relay instead.

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 server response carries credited: false / debited: false with a reason (not_headset, experimental, unknown_content, daily_cap…). Neither Lua call returns a value or takes a callback.

Design for this: don't gate progression on a spend actually succeeding. The locally cached getDreamPoints() moves by your delta even when the server didn't — confirm with dp.profile.refresh() before you rely on it.

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

Miss the headset, approval or attraction gate and the call still succeeds with the balance unchanged, so your logic runs the same everywhere. 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 preview key, valid for an hour, 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)
addDreamPointsServer balance unchangedYes
spendDreamPointsServer balance unchangedYes

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

Players in a DreamPark space 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 any network that lets devices talk to each other. Business Wi-Fi, a home router, 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 onmessage are just there.

New in the next SDK release

The platform is taking over the hard parts of multiplayer. The sections marked like this — leader, clock & randomness, shared state, physics props, attraction-relative positions, the send budget and simulated peers — describe the next SDK and DreamPark app. They aren't released yet, and names can still change. Until they ship, and for titles that must keep running on older apps, build on what's documented without a badge plus mp_kit.lua.txt from the SDK's multiplayer samples (rosters, leader, clock, counters, send budget, dedupe).

One thing to plan for: the release changes how Net IDs are computed and bumps the session protocol to 2, so headsets on an older app and on the new app never join the same session. A networked title moving to it should require the new app.

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.

Where one is installed, a DreamBox kiosk may act 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.

It works on a phone hotspot too. Two Quests tethered to someone's phone will find each other and play (hotspots can drop discovery broadcasts, so give it a moment). 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 the 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 costs a short hitch — about a second when it leaves cleanly, a few seconds when it simply disappears — and nothing else.

PropertyValue
Peers per session16 on a headset host (a Wi-Fi budget the host enforces — relay traffic scales with the square of the peer count). A DreamBox allows 32.
Max message size16 KB
Rate limit60 messages per second, per headset, counting everything that headset sends. Enforced when a headset hosts. A DreamBox doesn't limit, but design to 60 anyway — a kiosk session can fall back to a headset host mid-play. dp.relay().budget is that number.
Time to form a session~3–5 seconds from cold start
Who can hostQuest / Android and the Unity editor. iOS is client-only.

Headset-hosted sessions are scoped, so groups don't merge by accident: public-SDK builds sit on a separate channel from production builds, and peer sessions are further scoped by park. A DreamBox, or the local dev relay, is shared infrastructure — every build on the network joins it, whatever its channel or 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:

What changes in the next SDK New in the next SDK release

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.Next SDK In the next SDK the first object keeps the id and the second waits until it's released; rename rather than reach for explicitId.
Event for UNREGISTERED NetId <id>A message arrived for an ID that doesn't exist on this device yet. It's buffered, and harmless during startup — it's only a problem if the object never shows up, which means 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 defines neither onmessage nor onnet.

Messages that arrive before their object's script is listening are buffered (up to 32 per object) and delivered once it is, so you don't have to worry about startup races.Next SDK In the next SDK a buffered message expires after 5 seconds — stale state is worse than none.

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 a Lua table. Unity values convert themselves.
net_send("color", { tint = CS.UnityEngine.Color.red })

-- Receive: declare this at file scope. It fires on every OTHER device.
function onmessage(kind, payload)
    if kind == "color" then apply(payload.tint) end
end

That's the typed wire, and it's the one to reach for. net_send(kind, table) takes a Lua table, and onmessage(kind, payload) hands you the fields back — already unwrapped, so there's no envelope to remember and no JSON to build by hand.

What makes it worth using isn't the brevity, it's the coordinate handling: every Vector3, Quaternion, Transform and Color converts automatically, and positions cross the wire park-local — which is the difference between a shared object appearing in the same place on every headset and appearing two metres away. See Coordinates & the park frame; it's the bug this exists to prevent.

You sendPeers receive
A Vector3A point — converted to park-local going out, back into the receiver's own world coming in
dp.dir(v)A direction — rotated, never translated
A Quaternion, or a TransformA rotation, or a pose table {position, rotation, forward}
A ColorA Color, unchanged
Numbers, strings, booleans, nested tablesThrough unchanged, except numbers become 32-bit floats (whole numbers are exact up to 16,777,216 — keep ids as strings)

Next SDK In the next SDK, points sent from inside an attraction also travel relative to the attraction, which survives two headsets whose park calibration differs slightly. Nothing changes in your code — see Attraction-relative positions.

The string form still works, everywhere. net_send("color", '{"r":1}') with a function onnet(raw) receiver is the original API and nothing has deprecated it — every script written against it keeps running. If you're formatting your own JSON, read the envelope below, and convert your own coordinates with dp.to_park. New code is easier to get right with the typed form.

The envelope (string form)

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:

Two more things in that folder are worth your time before you write netcode. mp_kit.lua.txt is a set of reusable primitives — copy it into your content folder and use the parts you need rather than rebuilding them. MULTIPLAYER.md is the long-form guide: patterns for scores, ownership, spawns and shared clocks.

Next SDK In the next SDK the platform replaces most of mp_kit: its room, leader and clock become dp.leader and dp.clock, snapshot-and-sync code becomes dp.shared_state, and MULTIPLAYER.md is rewritten around them. mp_kit stays the fallback for titles that must run on older apps.

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. Queued briefly if the session isn't up yet; 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

Coordinates & the park frame

Unity world space is not the same on two headsets. Each Quest puts its tracking origin wherever it happened to boot, so transform.position means something different on every device in the room. Send one of those numbers over the relay and the receiver applies it in their world — the room lines up, the shared object doesn't, and the other player ends up standing two metres from where they really are.

The fix is a shared frame. A park is QR-calibrated into park-local space, which is identical on every headset. Attractions and props live in it.

Send park-local. Apply park-local. This is the most common multiplayer bug in DreamPark content, and it doesn't look like a coordinate bug — it looks like the network is fine and the other player is simply in the wrong place. Nothing warns you.

If you use the typed wire, this is already handled: net_send(kind, table) converts every Vector3 on the way out and back on the way in, and dp.peers() hands you Transforms that are already correct in your own world. You only need the helpers below when you're formatting your own JSON.

CallWhat it does
dp.park()The shared park frame (a Transform). Note this is not player.parent.
dp.to_park(v) / dp.from_park(v)A position, into and out of park space.
dp.to_park_dir(d) / dp.from_park_dir(d)A direction — rotated, never translated. Use these for velocities and aim vectors.
dp.to_park_rot(q) / dp.from_park_rot(q)A rotation.
dp.to_park_yaw(deg) / dp.from_park_yaw(deg)A yaw angle in degrees, Unity convention (0 = +Z, 90 = +X).
luafunction onready()
    local head = dp.head()                -- this headset's world space
    if head == nil or not net_send then return end
    net_send("look", { at = head.position, dir = dp.dir(head.forward) })  -- typed wire makes it park-local
end

Two things that catch people out:

In editor Play mode with no park in the scene, all of these are the identity transform, so desk testing behaves normally.

Attraction-relative positions New in the next SDK release

Park-local space is shared only as well as two headsets' QR calibrations agree, and in a real room they're off by a few centimetres and a degree or two. For anything that lives in an attraction — a boss, a ball, a target on the wall — what has to match is its place relative to the attraction. So in the next SDK, that's what goes on the wire.

CallWhat it does
dp.to_attraction(v[, go])
dp.from_attraction(v[, go])
A position, into and out of the attraction's frame. Outside an attraction they fall back to dp.to_park / dp.from_park.
dp.to_attraction_dir(d[, go])
dp.from_attraction_dir(d[, go])
A direction — rotated, never translated.

Inside a script, go defaults to the script's own object; pass another object to use its attraction.

lua-- string form: pack the numbers yourself
local p = dp.to_attraction(muzzle.position)
net_send("shot", string.format('{"x":%.3f,"y":%.3f,"z":%.3f}', p.x, p.y, p.z))

function onnet(raw)
    local t = json_parse(raw).payload
    spark_at(dp.from_attraction(CS.UnityEngine.Vector3(t.x, t.y, t.z)))
end

Players in the park

You never send player positions. The SDK already streams every headset's head and both hands — park-local, dead-banded, rate-limited and smoothed on arrival. Reading other players costs nothing, and you never send poses yourself. The stream does share your headset's relay allowance (up to about 12 messages a second while moving), so on a headset-hosted session plan on roughly 45 a second for your own traffic.Next SDK In the next SDK the platform's own services share the headset too, and your share is 24 a second — see The send budget.

luafor _, p in ipairs(dp.peers()) do
    -- p.head / p.left / p.right are Transforms in THIS headset's world
    look_at(p.head.position)
end
CallReturns
dp.me()Your own id — a stable string for this headset. This is the identity to put in your payloads.
dp.peers()An array of the other players, in the order this headset first heard them — don't derive teams or turn order from it (see mp_kit's new_room, or dp.rankNext SDK). It includes players of other titles in the park; p.game says which.
dp.peer(id)One peer, or nil. You are never your own peer.
dp.set_state(t)Publish a small table about yourself to everyone — team, colour, score, loadout. Resent to late joiners, so it's the right home for anything a guest arriving mid-round needs to know. Keep it small.
dp.on_peer_join(fn, owner)Fires once for everyone already in the session, then again for each arrival. Pass self as owner and the handler is dropped when your object is destroyed.
dp.on_peer_leave(fn, owner)Fires when a player goes.
dp.off_peer_join(fn) / dp.off_peer_leave(fn)Unsubscribe by hand, if you'd rather not use owner.

A peer entry carries:

FieldWhat it is
idTheir stable id — the key to store them under.
head, left, right, handTransforms, live, in your own world space — no conversion needed. hand is their currently active hand.
left_tracked, right_trackedWhether that hand is actually being tracked right now.
stateWhatever they last passed to dp.set_state, with Unity values restored.
nameTheir display name, or a stable Guest XXXX.
rigTheir RemoteRig clone in your scene, or nil.
active_hand"l" or "r".
avatarTheir avatar URL, or empty.
gameThe content ID they're playing.
seen, ageTime.time when we last heard from them (Time.time - p.seen is staleness), and seconds since this headset first heard them.

What other players see of you — RemoteRig

Put a child named RemoteRig in your Player.prefab. Whatever is inside it is your avatar: the SDK clones that subtree onto every other headset and drives it from your tracked body. Children named Head, LeftHand, RightHand and Hand follow automatically.

Lua scripts inside a RemoteRig run only on the clones other people are looking at, with peer_id injected — so tinting a remote player by their team is a couple of lines:

luafunction onready()
    local p = dp.peer(peer_id)
    if p and p.state and p.state.team then
        set_tint(p.state.team)
    end
end

There are no ownership components, no authority flags and no roles to configure — the clone simply isn't you. Clones are built from each viewer's own copy of your Player.prefab, so they appear on headsets running the same game; others just see the name tag. The SDK strips networking and tracking components from the clone but keeps your colliders, which is how local projectiles hit remote players.

Name tags are already on. Every remote player floats their display name above their head by default, so a space running entirely single-player attractions still feels inhabited. p.name is that same string if you want it in your own UI.

Leader, clock & shared randomness New in the next SDK release

Before a multiplayer game can do anything interesting it needs three things: someone who decides, a clock everyone agrees on, and dice that roll the same on every headset. Games used to build all three by hand, and most of the bugs lived there. In the next SDK the platform runs them — no component, no setup, and nothing your script has to send.

lualocal lastWave = -1

function start()
    dp.on_leader_changed(function(old, new)       -- fires once now with (nil, current), then on change
        show_crown(new == dp.me())
    end)
end

function update()
    if not dp.clock_synced() then return end
    local wave = math.floor(dp.clock() / 20)      -- every headset is on the same wave: zero messages
    if wave ~= lastWave then
        lastWave = wave
        open_gate(dp.random('wave:' .. wave .. ':gate', 3))   -- gate 1..3, the same one everywhere
    end
end
CallReturns
dp.leader()The id of the device that leads your title — the same answer on every device.
dp.is_leader()dp.leader() == dp.me(). Ask it every time it matters; never cache it.
dp.on_leader_changed(fn(old, new)[, owner])Calls fn(nil, current) straight away, then fn(old, new) on every change. Returns a handle (h.off()); dp.off_leader_changed(fn) works too. Registered from a script, it's dropped with that script.
dp.rank([id])A player's 1-based join order among your title's players (yours by default), or nil if they aren't here. Stable for everyone already in the room — use it for colours, teams and turn queues.
dp.clock()Shared session time, in seconds.
dp.clock_synced()false until this device has joined the room's timeline (and its seed).
dp.random(key)
dp.random(key, n)
dp.random(key, a, b)
A number in [0, 1); an integer 1..n; a number from a to b (an integer when both are integers). The same key gives the same number on every device.

Who leads

The clock and the dice

Shared state New in the next SDK release

An encounter — a boss's phase and health, a round timer, which wave is up — has to be one thing in the room, not one per headset. dp.shared_state(name) is a replicated table with one writer: everyone reads it, the writer applies every change, everyone else sends requests, and anyone who walks in late gets the whole table automatically. Here's a complete boss, condensed from the SDK's mp_encounter_sample.lua.txt:

lua-- cake_boss.lua.txt, on the boss's manager object inside your attraction
local s                                            -- the whole encounter

local function pick_target()
    local ids = { dp.me() }
    for _, p in ipairs(dp.peers()) do
        if p.game == dp.game_id(self.gameObject) then ids[#ids + 1] = p.id end
    end
    table.sort(ids)
    return ids[dp.random('target:' .. s.get('epoch', 0), #ids)]
end

-- Runs on the WRITER only. Every change to the fight is decided here, so two
-- players pressing Start in the same frame get one fight: the second request
-- finds the phase already 'fight' and does nothing.
local function on_request(kind, p, from)
    if kind == 'start' and s.get('phase', 'idle') ~= 'fight' then
        s.set('epoch', s.get('epoch', 0) + 1)
        s.set('hp', 50)
        s.set('t0', dp.clock())                    -- the fight's timeline, shared
        s.set('target', pick_target())             -- the writer picks; everyone sees it
        s.set('phase', 'fight')
    elseif kind == 'hit' and s.get('phase') == 'fight' then
        local hp = math.max(0, s.get('hp', 0) - (p.n or 1))
        s.set('hp', hp)
        if hp == 0 then s.set('phase', 'won') end
    end
end

function start()                                   -- never at file scope or in awake()
    s = dp.shared_state('boss')
    s.on_request(on_request)
    s.on_change(function(key, value)               -- fires for what's already there, then on each change
        if key == 'phase' then play_phase(value) end
        if key == 'hp' then set_health_bar(value / 50) end
    end)
end

function on_start_pressed()                        -- a button, a touch
    if not s.synced() then return end              -- a late joiner may not start anything
    s.request('start', {})
end

function on_boss_hit(n)                            -- THIS player landed a hit
    if not s.synced() then return end
    s.request('hit', { n = n })                    -- the writer applies the damage
    s.add('damage', n)                             -- my running total: a merge cell, no round trip
end

function update()
    if s == nil or not s.synced() or s.get('phase') ~= 'fight' then return end
    animate(dp.clock() - s.get('t0'), s.get('target'))   -- same moment, same target, every headset
end

Registers

CallReturns / does
s.get(k[, default])The current value, or default.
s.set(k, v)Writes a value (nil deletes it). On the writer it applies now; anywhere else it's sent to the writer, which applies it. When the change needs a rule ("only if idle"), use request instead.
s.request(kind, payload)Asks the writer to run its on_request handlers. On the writer itself they run at once.
s.on_request(fn(kind, payload, from)[, owner])Runs on the writer only. from is the sender's id.
s.on_change(fn(key, value)[, owner])Fires for every key already present, then for each change (value is nil when deleted).
s.synced()Whether this device has the room's state yet.
s.rev(), s.keys()The table's revision (0 = nothing written yet); its keys.
s.leader(), s.is_leader()This state's writer, and whether it's you.
s.off_change(fn), s.off_request(fn)Unsubscribe. Both on_ calls also return a handle with h.off(), and inside a script they're dropped with it.

Merge cells: everyone writes their own part

Some state is written by every player at once — damage per player, claimed chests, ready flags. Sending all of that through one writer is slow and fragile, so these keys are merge cells: each device writes only its own part, directly, and every device merges. Nothing conflicts, and nothing waits on a round trip.

CallUse it for
s.add(k, n) / s.total(k)A grow-only counter: damage dealt, coins collected. add grows your part (by a positive n) and returns your part; total — and s.get(k) — is everyone's sum.
s.max(k, n)The largest value anyone offered — the room's best time, the furthest wave. Returns the merged maximum.
s.union(k, items)
s.has(k, item) / s.items(k)
A grow-only set of strings (each up to 64 characters): chests opened, keys found. union takes one item or a list and returns true if anything was new.
s.mine(k, v)
s.of(k[, id]) / s.all(k)
One value per player, written only by that player: loadout, ready flag, per-player score. of reads one player's value (yours by default); all returns { [id] = value }.
s.clear(k)Writer only: empties a cell everywhere so it can be reused next round. Anyone else gets a console warning and false — send a request and let the writer clear it.

A key is either a plain value or a merge cell, never both.

The rules it runs by

Physics props New in the next SDK release

A ball, a crate, a party hat — anything players push around. If every headset simulates it, each one ends up with its own copy somewhere different. dp.net_body makes a Rigidbody one shared object: one owner simulates it while it moves, and everyone else follows.

lua-- on a GameObject with a Rigidbody and a collider, inside your attraction
local body

function start()
    body = dp.net_body(self.gameObject, { max_speed = 2.6, max_spin = 9, max_push = 1.2, home = true })
    body.on_owner_changed(function(old, new)
        set_glow(new == dp.me())
    end)
end

That's all — no NetId and no component to add; the platform attaches what it needs at runtime. The prop has to be part of your attraction prefab (pre-placed, not instantiated), for the same reason as every networked object.

OptionDefaultMeaning
max_speed2.6Speed cap while simulated here, m/s
max_spin9Spin cap, rad/s
max_push1.2Depenetration speed, m/s — how hard overlapping colliders push it out
rate10The owner's pose stream, Hz (1–10)
hometruePut it back when it falls out of the room
home_fall, home_radius1.5, 6Metres below / away from where it last rested that count as "out of the room"
HandleReturns / does
body.owner(), body.is_mine()Who's simulating it right now (nil when it's at rest), and whether that's you.
body.claim(), body.release()Take it and keep it — even while it's still, say carried in a hand — until you release it. claim returns false while someone else is streaming it; release lets go once it stops moving.
body.home(), body.home(pos)Send it home now / set a new home, in world space.
body.on_owner_changed(fn(old, new)[, owner])Fires when ownership changes. Returns a handle (h.off()).

The send budget New in the next SDK release

A headset relay drops message 61 of any second from one headset, silently — and that counts everything the headset sends. In the next SDK the platform's own services send too, so the 60 is split once, up front, and nothing grows into anyone else's share:

SharePer secondWho
Presence≤ 12Your head and hands for other players
SDK services≤ 24The session (about one beat a second), every dp.shared_state and every dp.net_body, from one pool. Must-arrive messages — snapshots, write requests, claims, settled poses — go first.
Your game24Your own net_send
Floor60What dp.relay().budget reports

The SDK doesn't throttle your net_send to 24 — keeping your title's own traffic inside it is your job, and the guarantee is that the platform will never eat into it. Check it with dp.budget():

lualocal b = dp.budget()
-- b.floor = 60, b.presence = 12, b.sdk = 24, b.game = 24
-- b.this_second, b.last_second   everything this headset sent, this and last second
-- b.remaining                    what's left of the 60 this second, all senders included
-- b.sdk_available, b.sdk_sent, b.sdk_denied   the SDK pool

The budget scenario measures a real session: peak rate, relay drops and a breakdown by message kind.

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 onmessage(kind, payload) (or onnet(raw)) at file scope 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 UDP port 7777 and advertises itself like a DreamBox, with a web panel at http://127.0.0.1:7780 showing connected peers and live traffic. It needs the .NET 9 SDK, or run DreamPark → Multiplayer → Rebuild Binaries once. Your scene also needs a networking client: in the DreamPark app it's always there, but in your SDK project it's only in the test scene — run DreamPark → Multiplayer → Create Test Scene, or add DreamBoxClient and NetSessionArbiter to a GameObject in your own test scene. 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.

Testing with simulated peers New in the next SDK release

The bugs that matter in multiplayer — a private boss on each headset, a late joiner who never syncs, two leaders, a prop flung across the room — are invisible to one player in the Editor. Simulated peers put more players into your Play session, so you find them at your desk instead of at a two-headset playtest:

menuDreamPark → Multiplayer → Simulated Peers...

The six scenarios

ScenarioWhat it proves
late_joinThe encounter starts, then a player joins mid-fight: they see everyone, their replica holds the same state (revision and values), positions on the wire are park-correct, the encounter stays healthy and the leader doesn't change.
simultaneous_startYou and a peer start in the same frame: exactly one encounter.
leader_leavesThe most senior player leads, then crashes: leadership comes back to you, everyone agrees, and the encounter carries on from where it was.
shared_propYou and a peer touch the same dp.net_body prop at once: one owner, no fling, settled and consistent.
budgetThree peers (one a phone) move around while your game runs: peak at most 60 a second, zero relay drops, a breakdown by message kind.
other_titleA senior player of another title, and one in another attraction: your game counts only its own players and keeps its own leader.

Each scenario becomes a test of your game through a few parameters: the path of your manager object, and Lua expressions run in its scope to start the encounter, check it's healthy and check there's only one. mp_encounter_sample.peers.json, next to the SDK's multiplayer samples, is a complete set. A parameter you leave out skips its steps and says so — never a silent pass. Results land in Logs/DreamParkPeers/<time>_<name>/ (result.json and log.txt).

shellTools/agent/u.sh editor_play
Tools/agent/u.sh --timeout 400 dreampark_peers_scenario --name all --wait true --params '{
  "target": "A_CakeParty/Cake Boss", "start": "on_start_pressed()", "healthy": "healthy()",
  "one_encounter": "one_encounter()", "state": "boss", "peer_request": "start",
  "body": "A_CakeParty/Gift", "body_touch": "claim()", "activity": "on_boss_hit(1)" }'
Tools/agent/u.sh editor_stop

The same commands drive peers by hand: dreampark_peers_add --name B [--rank low] [--device phone], dreampark_peers_act (one scenario step), dreampark_peers_inbox --name B (what B received), dreampark_peers_remove --name B --mode crash, and dreampark_peers_status (each peer's leader, sync and replica revisions).

Writing your own scenario is a JSON file: parameters, plus a list of steps that either do something (peer_add, peer_walk, peer_hand, peer_send, peer_request, peer_push, peer_link, peer_remove, local_rejoin, local_lua, wait…) or expect something, optionally within a few seconds (lua, recv, no_recv, sees, local_peers, leader, state_agrees, budget, net_body, no_errors). Pass a file path or inline JSON as --name. The full step list is at the top of Assets/DreamPark/Editor/ParkSimulator/ParkSimPeerScenario.cs.

json{
  "name": "door_late_join",
  "params": { "target": "A_Vault/Door" },
  "steps": [
    { "do": "peer_add", "name": "B" },
    { "expect": "local_peers", "count": 1, "within": 8 },
    { "do": "local_lua", "target": "${target}", "code": "open()" },
    { "do": "mark", "name": "join" },
    { "do": "peer_add", "name": "C" },
    { "expect": "state_agrees", "peer": "C", "name": "door", "within": 5 }
  ]
}

What simulated peers can't show: two copies of your game's Lua disagreeing with each other, real Wi-Fi loss and latency, and tracking and park alignment between two real headsets. So after the scenarios pass, do one two-headset late-join test: headset A starts the encounter, headset B joins late and walks to the same spot. Both must see the same phase, health and target, and B's name tag must stand on B.

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. The host is an implementation detail that can change mid-session — when someone takes their headset off, or when a DreamBox kiosk appears and everyone migrates to it. 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.

dp.session() does report is_host, but treat it as diagnostic — something to print on a debug panel, not to branch gameplay on. Any logic that only runs on the host stops running the moment the host changes, which it will, mid-session, with no warning.

Next SDK When something genuinely needs one decider — a boss, a round, a spawn — the next SDK gives you one that doesn't move with the host: dp.is_leader(), and the writer of a dp.shared_state. Last-write-wins stays right for cosmetics.

Messages aren't retained 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 — no message you sent before they arrived will ever reach them.

There are two ways out, and the first is usually the right one:

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

Next SDK In the next SDK, world state you keep in dp.shared_state reaches late joiners automatically, with no heartbeat of your own; pushed props get the same from dp.net_body.

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

Budget your messages

Sixty messages per second, per headset, is the ceiling — anything over it is dropped by a headset host. The host logs [PeerRelay] Rate limit: dropped N message(s) and the sender warns about its send rate, but nothing errors in your Lua. 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.Next SDK In the next SDK your title's own share is 24 a second: The send budget.

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 and network the enable/disable — or spawn by recipe: send the origin, direction and fire time, and let every client instantiate its own copy.
Late-join sync for world stateRebroadcast on a slow heartbeat. (Player state is covered — dp.set_state is resent for you.)
Authority / ownershipDesign for last-write-wins.
Unreliable or unordered channelThrottle and interpolate rather than flooding.
Cross-network or internet playSessions are local to one network by design.

Next SDK The next SDK fills three of those rows: late-join world state (dp.shared_state), authority (dp.leader, the shared-state writer, a dp.net_body owner) and shared physics. Spawns are still recipes or pre-placed objects, and the channel is still reliable and ordered.

The rules for a networked attraction New in the next SDK release

Each of these broke a shipped game.

  1. Encounters have one writer. Bosses, enemies, rounds, timers and spawns live in dp.shared_state; the writer applies changes and everyone else sends requests.
  2. Nothing a player can see is rolled per device. Targets, attack choice, AI wander, gaze and spawn order come from the writer, dp.random(key) or dp.clock() — never math.random, never math.randomseed.
  3. Starts are gated. An unsynced late joiner may not start or reset anything (s.synced()), and a start is a request, so two at once are one fight. Effects never replay to late joiners.
  4. Pushed props go through dp.net_body. Never write a remote prop's transform, and never simulate a prop you don't own.
  5. No world coordinates on the wire. Typed net_send converts for you; players come from dp.peers(); nobody streams player positions.
  6. Don't set explicitId except on a Player.prefab bus. One hashed NetId on the attraction's manager; props and buttons call the manager.
  7. Music, voice-over and greetings start in onzoneenter(), never in start(). In the next SDK this also fires inside a Dream Sequence, where the active level is the attraction.
  8. One send budget per headset — design your own traffic for 24 a second, inside the floor of 60.
  9. Test before a headset does: all six simulated-peer scenarios, then one two-headset late-join test.

And two lifecycle facts under all of them: nothing sends at file scope or in awake() — do it in start() or later, when the object is placed and its Net ID is final — and ondisable() is not "the player left": the rig hides in other titles' zones and the optimizer parks distant objects. Never leave a room or drop a lease there.

Moving a title to the next SDK New in the next SDK release

mp_kit.lua.txt keeps its counters, send-budget and dedupe helpers for anything the platform doesn't cover; its room, leader and clock predate the platform's, so on the next SDK use dp.leader and dp.clock, and keep mp_kit for titles that must run on older apps.

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 ignore each other's headset-hosted sessions; both will still join a DreamBox or the dev relay.
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 nilThe object has no NetId on the same GameObject as the LuaBehaviour, or you called it at file scope. Call it from awake() or later.
…with no DreamBoxClient in the scene — running single-playerThe scene has no networking client. See Testing on your machine.
Messages stop under loadYou're over 60/second — look for [PeerRelay] Rate limit on the host. Throttle at the source.
Name tags work, but nothing
your game sends arrivesNext SDK
Both headsets must log the same [NetRegistry] Registered NetId N for your manager. A mismatch is almost always explicitId on attraction content.
Two fights, two targets,
two of anythingNext SDK
Something was written outside the shared-state writer, or rolled with math.random. Run the simultaneous_start scenario.
A late joiner sees
a fresh encounterNext SDK
It started one while unsynced. Gate starts on s.synced(). A console line [SharedState] '…': dropped N write(s) made before this device synced means the platform already stopped one.
Positions right in the Editor,
metres off on a headsetNext SDK
World coordinates on the wire. Run a scenario (they misalign the Editor's frame) or use typed net_send.
Works solo, breaks
with three or moreNext SDK
The budget. Check dp.budget() and run the budget scenario.
Old-app and new-app headsets
never see each otherNext SDK
Working as intended: session protocol 2. Ignoring peer beacon names the protocol version. Update both.

Pre-upload checks

When you press Start in the launch window, the SDK first checks your Lua for APIs the headset sandbox denies — those work in the Editor and throw on a headset, so they stop the upload outright. Then it scans your content folder for a specific set of problems that are cheap to fix now and expensive to discover after a release. Each finding offers a direct fix, a guided resolver, or a jump to the exact object that needs your judgment. You can run the full suite any time from Pre Launch Options → Review Pre-Upload Checks... in the Content Uploader.

When nothing is wrong, you'll never see this. No window, no dialog, no extra click — a clean project uploads exactly the way it did before the checks existed. That's deliberate: a gate that interrupts every upload is a gate people learn to click through, and then the one that mattered gets clicked through too.

Blocking findings stop the upload; the button reads "Fix N blocking issues to continue". Warnings open the window, but Continue · Upload ships past them once you've read them. Info findings never interrupt. If a finding is genuinely wrong for your content, Ignore… records it, with a reason you type, in Assets/Content/<your-id>/.preupload-ignores.json — commit that file so your whole team sees the decision.

CheckSeverityWhat it catches
Package referencesBlocksA placement in your Sequence or a finished Adventure (one with both a Start and an End Point) points at a prefab that was deleted or is no longer an Attraction or Prop in this content folder. Replace or remove it in the Packages organizer.
Sequence packageBlocksEvery title needs a valid Sequence. Blocks when it isn't built or is out of date, when the Start, Overlay or Game Over level or the Game Manager is missing, when a special level has no packing bake, when no attraction fits the 12 × 18 ft room, or when a required attraction is too big. The fix button rebuilds the package.
Duplicate prefab namesBlocks (case-only clashes: Warning)Two content root prefabs share a name, so they collide on their Addressables address, their preview image and the resourceName used for revenue attribution. Offers a rename.
Directional lights in contentBlocks (inactive lights: Warning)A directional light inside shipped content. It has no position and no falloff, so it re-lights the entire space — including everybody else's attractions.
Materials without Meta OcclusionBlocks (can't tell: Warning)A material whose shader doesn't integrate Meta environment occlusion — usually a leftover Standard or asset-pack shader. Editable materials convert from the finding; materials embedded in a model can be extracted first.
Opaque materials with Alpha Clipping offBlocksA DreamPark-shader material set to Opaque that isn't genuinely clipping — invisible everywhere except on a headset in passthrough. Repaired from the finding. See Materials & shaders.
Runtime components on a content rootBlocks (trigger colliders: Warning)A Rigidbody, collider, AudioSource, particle system, Animator/Animation, light or camera on the root GameObject of an attraction or prop. The park loader only manages the root's children, so these quietly don't behave the way they do in your scene. Move them to a child — the finding opens Prefab Mode on the exact component.
Dependencies outside the content folderWarning (ThirdPartyLocal/ and protected folders: Info)Your package depends on assets outside its content folder, so they won't ship with it. A guided resolver moves unshared assets in and copies or repoints shared ones; Manage Third Party Assets also helps.
resourceName vs. addressWarning (never stamped: Info)A prefab's stored revenue key no longer matches its computed address, most often after "Revert All" on a prefab variant. Re-stamp it from the finding.
Badges awarded somewhereWarningA badge in your catalog isn't awarded by any shipping Attraction, Prop or Player script.
Hand-held propsNext SDKWarning (minor findings: Info)A prop in a hand on your Player, an Attraction or a Prop that will sit wrong on the headset: a grip more than 10° or 2 cm off the device pose for its side (unless a script mounts it with dp.hold or handheld_mount), a missing Grip, a negative scale, a remote copy posed differently from the local one, a prop hung on an old template pivot — plus each prop's own markers, scale, size and Muzzle. Opens Prefab Mode on the object. See Hand-held objects.
Unapplied scene overridesWarning, review onlyPrefab instances in your scenes carrying overrides never applied back to the prefab asset — changes that look right in your scene and are missing from what you ship. Runs from Review Pre-Upload Checks..., not at upload, because scenes never ship and opening every one can disturb your setup.

If the check suite itself fails to run, the upload proceeds and logs a warning. That's on purpose — a broken safety net shouldn't be able to stop a release.

Publishing

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

Fill in, in the panel itself:

You need to be signed in, and the folder has to contain at least one Attraction or Prop — until it does, the upload button stays disabled and says so. The reserved Sample folder can't be uploaded.

Then hit Upload Release. That opens the launch window, which shows your name and description read-only. There you write release notes — required, and shown to players in the DreamPark app — and confirm build targets: Android and iOS are locked on, and Editor (Mac) and Editor (Windows) are toggles, on by default and expected for an official release. Hit Start · All (the label follows your upload scope): the pre-upload checks run, your build is packaged as Unity Addressables for each target, and it's pushed to DreamPark.

A new version is playable in the DreamPark app right away under Experimental Mode. It reaches everyone — and can award and spend DreamPoints — once it's approved.

Every upload is a full re-upload by default, which is also the only valid mode for a first publish — patching needs a baseline to diff against. Once you've published once, the launch window's Upload Scope picker offers two much cheaper modes:

Both are worth switching to as soon as you're iterating rather than launching — Code only in particular turns a Lua tweak into a few seconds of upload instead of a full rebuild, and the picker remembers your choice.

You don't have to enable anything for this. The SDK's Smart bundling strategy — which is what makes Patch and Code only possible — has been the default and the only strategy in the UI since August 2026. The old Legacy strategy (one bundle per top-level folder, so a single changed texture re-shipped everything in that folder) is deprecated and survives only as a support fallback under DreamPark → Troubleshooting → Use Legacy Bundling (deprecated). If you're on a project that predates the change, the editor migrates you to Smart automatically — the first build after a strategy change looks like a full re-upload, because every group re-partitions.

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.

Upload Beta, next to it, builds the same folder to a separate content ID (<your-id>Beta) with its own version history and release notes. Use it to put a build in front of testers without touching your release title.

Automating releases? The SDK ships a typed Unity CLI command, dreampark_content_publish, plus a strict preflight; RELEASE.md in the repo describes the workflow for release agents. For unattended runs, DreamPark → Generate Release Token mints a scoped token so a CI job can publish without the email-code sign-in. Release notes are still required, and a failed check still stops the release.

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, removes any SDK files the new version retired, and opens Unity's standard import dialog so you can review what's changing. Click Import and you're on the new version.

The check needs you to be signed in, runs once per editor session, and the release notes cover every version you skipped. You can dismiss the popup (Remind Me Later snoozes it for 24 hours) and keep editing, but you can't publish on an out-of-date SDK: the Content Uploader re-checks the version on every upload and disables uploading until you update (it only lets you through if the version server can't be reached). Skip This Version is also there, but it won't unlock uploading. Import everything under Assets/DreamPark/ — if you untick files, the editor tells you the update didn't complete. The Developer Terms ask you to ship compatibility updates within 30 days of a new SDK release.

Optimization

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

Every tool previews its changes row by row before touching a file, and preserves GUIDs. DreamPark → Diagnostics → Bundle Size Breakdown... shows what's actually taking up space. Run them before each publish — they make a real difference to download size on real-world networks. They're optional, and all of them are buttons in the Content Uploader's Pre Launch Options. The pre-upload checks are separate: they run automatically and are about correctness, not size.

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. The Content Uploader shows a Set Content ID button for the placeholder, or Fix Folder Name for a folder with an invalid name. Both reopen 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. Sessions last 14 days. 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: Check Git LFS, Regenerate Level Previews, Generate XLua Code / Verify XLua Codegen, Cleanup Addressables, Remove Broken Addressables and Fix Script Namespaces. 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

Lua scripts are plain .lua.txt TextAssets, so a new or renamed script needs no codegen — check that the LuaBehaviour points at the right asset. If Lua calls a Unity type that works in the editor but fails on device, run DreamPark → Troubleshooting → Scan Lua API Surface and Verify XLua Codegen. To get Lua to live headsets, upload with the Code only scope — no app release needed.

Pink materials, silent audio or missing meshes

The repo was cloned without Git LFS, so those assets are text placeholders. Run DreamPark → Troubleshooting → Check Git LFS, or git lfs install && git lfs pull in the project folder.

Console errors on first open

Let Unity finish downloading packages. Still broken? Close Unity, delete the project's Library folder, and reopen.

The Quest doesn't appear when you press Play

Check Developer Mode is on, that the USB-C cable carries data (not charge-only), and that you accepted the USB debugging prompt inside the headset.

My attraction doesn't appear in the app

Turn on Experimental Mode in your park settings, and make sure the app is signed in to the same account as the Content Uploader. If it's missing from the Attractions browser specifically, check the prefab root has an AttractionTemplate (see How your content gets addressed).

It loads on device but doesn't behave

New C# scripts need manual review and a future app release before they run on device. Move the logic to Lua.

Get support

The fastest place to reach us: