xr/studio Documentation
Custom Code API
Script your story with the Custom Code action: the cyango namespace, its methods and types, reading GUI inputs, variables, navigation, and examples.
Custom Code
The Custom Code action runs TypeScript when its event fires, such as On Click, On Scene Ready, or On Language Changed. Use it when the built-in action types are not enough, for example to read form inputs, branch on device or language, keep values between scenes, or drive the story directly.
To add it, open the Actions tab on an entity (or select the scene), pick an event type, and add a Custom Code action. The editor has TypeScript autocomplete — type cyango. to see what is available.
When you save the story, Studio compiles every custom code block to JavaScript. The player runs the compiled output only, so you get type-aware editing without shipping the TypeScript compiler to viewers. Fix any compile errors shown in the editor before publishing.
The sandbox
Your code runs inside a secure sandbox, wrapped in an async function. In practice:
- You can use
awaitdirectly, andreturnto stop early. console.log,setTimeout,setInterval(capped at 60 seconds) andfetchare available.- DOM globals are blocked:
window,document,location,navigator,history,eval. Usecyango.utils.getWindowLocation()andcyango.utils.getNavigator()instead. - Errors are caught and logged to the browser console, so a broken snippet will not crash the story.
Everything Cyango exposes lives on one global object, cyango.
Head and Footer code
A story also has two blocks that run once, when the story loads. Open Settings, then Head and Footer Code. Head runs first, and Footer runs last. Both use the same editor and the same sandbox as a Custom Code action, with their own snippets in the panel beside the code.
Use them to prepare the story before a visitor sees it, or to report on it afterwards:
- Set the language or the quality level from the URL, before the first scene is shown.
- Load a configuration from your backend into variables that every action can read.
- Install Google Analytics, Google Tag Manager or any other tag.
- Write the SEO and sharing tags: canonical, robots, Open Graph, Twitter cards and JSON-LD.
The head object
These two blocks get one thing no other block has: head, which writes into the page. document and window stay blocked everywhere, so this is how a tag gets on the page.
| Method | What it does |
|---|---|
head.addScript(src, options) | Loads an external script, once. await it when the tag has to be configured afterwards |
head.addInlineScript(code, options) | Runs JavaScript in the page, which is the only way to reach window globals such as dataLayer and gtag |
head.setMeta(name, content) | Sets a name meta tag, such as description or robots |
head.setMetaProperty(property, content) | Sets a property meta tag, which is what Open Graph uses |
head.setLink(rel, href, options) | Sets a link tag, such as canonical or an alternate per language |
head.setJsonLd(data, options) | Writes a JSON-LD block |
head.setTitle(title) | Sets the page title. The story name is used by default |
Every call is idempotent. A block that runs again updates its tags instead of adding a second copy, so a tag is never loaded twice and a visit is never counted twice.
A Custom Code action has no head. To send an event from an action, use the Google Analytics Measurement Protocol snippet, which is only a fetch and works in any block.
The cyango object
There are five namespaces:
| Namespace | What it holds |
|---|---|
cyango.utils | Helpers: find entities, store variables, read GUI inputs, detect device |
cyango.storyState | Read and drive the story: scenes, language, assets, XR |
cyango.uiState | App and interface state: loading, camera controls, modals |
cyango.timelineState | Scene timeline and media playback |
cyango.types | Enums used as arguments and for comparisons |
Values you read are live, such as cyango.storyState.activeSceneId. Functions you call perform an action, such as cyango.storyState.setActiveScene(id).
Variables
Global variables last for the whole session and survive scene changes. Use them to pass data from one action to another.
| Method | Description |
|---|---|
cyango.utils.setGlobalVar(key, value) | Store any value under a key |
cyango.utils.getGlobalVar(key) | Read a stored value |
cyango.utils.deleteGlobalVar(key) | Remove one key (returns boolean) |
cyango.utils.clearGlobalVars() | Remove all variables |
cyango.utils.globalVars | The whole record, as a snapshot |
// On a button click: count presses, then act on the third
const clicks = (cyango.utils.getGlobalVar('clicks') ?? 0) + 1;
cyango.utils.setGlobalVar('clicks', clicks);
if (clicks < 3) return;
const scene = cyango.storyState.activeStoryJson.scenes.find((s) => s.name === 'Reward');
if (scene) cyango.storyState.setActiveScene(scene.id);
Finding entities and scenes
| Method or value | Returns |
|---|---|
cyango.utils.getActiveScene() | The current scene (IScene), or null |
cyango.utils.getEntityByName(entities, name) | First entity with that name, or null |
cyango.utils.getEntityById(entities, id) | Entity by its id, or null |
cyango.utils.thisEntity | The entity this action is attached to |
cyango.utils.getAssetUrl(assetId?) | Story asset URL by id; no argument uses this entity's assigned asset |
cyango.storyState.activeSceneId | Id of the current scene |
cyango.storyState.activeStoryJson | The whole story document, including .scenes |
const scene = cyango.utils.getActiveScene();
const logo = cyango.utils.getEntityByName(scene.entities, 'Logo');
Reading GUI input fields
Input field entities capture whatever the visitor types. Read or set that value by the input's entity id. The value is kept even if the field is hidden and shown again.
| Method | Description |
|---|---|
cyango.utils.getGuiInputValue(entityId) | Current text (string), or null if never set |
cyango.utils.setGuiInputValue(entityId, value) | Set the field's text |
cyango.utils.clearGuiInputValue(entityId) | Empty the field |
// On a Submit button: read an email field and store it
const scene = cyango.utils.getActiveScene();
const emailField = cyango.utils.getEntityByName(scene.entities, 'EmailInput');
const email = cyango.utils.getGuiInputValue(emailField.id);
cyango.utils.setGlobalVar('email', email);
cyango.utils.setGuiInputValue(emailField.id, ''); // clear after submit
Scrolling GUI containers
For GUI containers whose content scrolls, such as lists and panels:
| Method | Description |
|---|---|
cyango.utils.getGuiScrollPosition(entityId) | Current { x, y } offset (call trackGuiScroll first) |
cyango.utils.getGuiMaxScrollPosition(entityId) | Maximum scroll offset |
cyango.utils.setGuiScrollPosition(entityId, x, y) | Jump to an offset |
cyango.utils.scrollGuiBy(entityId, dx, dy) | Offset from the current position |
cyango.utils.trackGuiScroll(entityId) and untrackGuiScroll(entityId) | Start and stop recording the user's scroll |
cyango.utils.lockGuiScroll(entityId) and unlockGuiScroll(entityId) | Block and allow user scrolling |
Navigation, language and links
| Method or value | Description |
|---|---|
cyango.storyState.setActiveScene(sceneId) | Go to a scene, optionally with a transition |
cyango.storyState.setActiveLanguage(lang) | Switch language (fires On Language Changed) |
cyango.storyState.triggerAction(action, entityId?) | Run an action object directly |
cyango.uiState.openUrl(url) | Open an external URL |
cyango.storyState.activeLanguage and availableLanguages | Current and available languages |
// Send Portuguese visitors to a localized scene
if (cyango.storyState.activeLanguage === cyango.types.LanguageTypes.pt_PT) {
const scene = cyango.storyState.activeStoryJson.scenes.find((s) => s.name === 'Intro PT');
if (scene) cyango.storyState.setActiveScene(scene.id);
}
AR and VR
| Method or value | Description |
|---|---|
cyango.storyState.enterAR(), enterVR(), exitXR() | Enter or exit immersive modes |
cyango.storyState.isAR, isVR, isMobileAR | Whether a mode is currently active |
Timeline and media
| Method or value | Description |
|---|---|
cyango.timelineState.setPlayingMode(mode) | Play or pause the scene timeline. See cyango.types.PlayingModes |
cyango.timelineState.controlMedia(mode) | Control scene media: 'play', 'pause', 'stop', 'mute', 'unmute', 'seek' |
cyango.timelineState.setElapsedTime(ms) | Seek the timeline |
cyango.timelineState.elapsedTime | Current time, in milliseconds |
cyango.timelineState.setPlayingMode(cyango.types.PlayingModes.PLAY);
Device and environment
| Method or value | Returns |
|---|---|
cyango.utils.detectDeviceType() | "iPad", "iPhone", "Android", "Windows", "Mac", or "Unknown" |
cyango.utils.detectBrowser() | Browser name, such as "Apple Safari" or "Google Chrome" |
cyango.uiState.selectedBreakpoint | Current breakpoint. See cyango.types.BreakpointStateTypes |
cyango.utils.getWindowLocation() | The Location object, including URL and query string |
cyango.utils.getNavigator() | The Navigator object |
if (cyango.utils.detectDeviceType() === 'iPhone') {
// show a mobile-specific hint
}
Types
cyango.types provides the enums used across the API. Use them for arguments and comparisons:
ActionType, EventType, PlayingModes, TransitionType, EntityTypes, MaterialTypes, BreakpointStateTypes, SceneTypes, AssetCategories, AssetFileTypes, AssetMimeTypes, LanguageTypes.
Example: a sign-up gate
// On Click of a Continue button
const scene = cyango.utils.getActiveScene();
const nameField = cyango.utils.getEntityByName(scene.entities, 'NameInput');
const name = (cyango.utils.getGuiInputValue(nameField.id) ?? '').trim();
// Nothing typed, so clear the field and stay on this scene
if (!name) {
cyango.utils.clearGuiInputValue(nameField.id);
return;
}
// Remember the name, send it to your backend, then continue
cyango.utils.setGlobalVar('visitorName', name);
await fetch('https://example.com/signups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const next = cyango.storyState.activeStoryJson.scenes.find((s) => s.name === 'Welcome');
if (next) cyango.storyState.setActiveScene(next.id);