Complete guide for building interactive 3D spaces in Portals...
Portals is a platform for creating interactive 3D virtual spaces. This guide covers all building tools, the Interactive Studio (no-code gamification), and advanced scripting with Function Effects.
| Tool | Description |
|---|---|
| AI Item Generator | Automated 3D object creation |
| Image | Place 2D images in 3D space |
| Video | Embed video players |
| Build Block | Basic structural elements |
| Screenshare | Display sharing |
| Portal | Teleportation between spaces/locations |
| Custom GLB | Import custom 3D models |
| Light Source | Static lighting |
| Blink Light | Animated/flashing lights |
| Spotlight | Directional lighting |
| Spawn Point | Player entry locations |
| NPC | Non-player characters with dialogue/AI |
| Billboard | Text/image display surfaces |
| Jump Pad | Launch players into air |
| Trigger Cube | Invisible interaction zones |
| Collectible GLB | Gatherable 3D objects |
| Leaderboard | Score tracking display |
| Elemental | Environmental effects |
| Chart | Live data visualization (crypto) |
| World Text | 3D text in space |
Creates teleportation between spaces or spawn points.
Configuration:
Invisible zone that activates events when players enter.
Settings:
Important: Trigger cubes only activate when a player enters the cube (crosses the boundary from outside). If a player spawns or loads into the game already inside a trigger cube, the trigger will NOT fire.
Organization Tip: Place related triggers on Build Blocks (which have a color selector), give them distinct colors, and add On Hover Text Display labels to organize your game logic.
Interactive characters with dialogue trees and AI.
Setup:
NPC-Specific Effects:
Requirement: Must use rigged GLB avatars for animations.
The no-code system for gamifying spaces. Three core components:
Tasks are progress trackers with three states:
Tasks can transition between states in any order via triggers.
Task Types:
Access via Space Options > Task Debug Panel
Important: Task system must be turned on BEFORE opening debug panel.
State Indicators:
Features:
Visual graph interface for analyzing game logic.
Access: Space Options > Tasks > Click 'Graph' button
Node Colors:
Features:
Triggers are events that cause tasks to change state. Understanding when each trigger fires is critical for building reliable game logic.
What it does: Fires when a player uses an item from their backpack/inventory.
Use cases:
Example: Player clicks "Health Potion" in backpack → trigger fires → effect heals player
What it does: Fires when a player clicks on the object this trigger is attached to.
Use cases:
Important: The click must be on the specific object. For area-based interactions, use User Enter Trigger instead.
Example: Player clicks a lever → trigger fires → door opens
What it does: Fires when the player's physics collider touches a trigger volume. This is for physics-based interactions.
Use cases:
Important: For simply detecting when a player walks into an area, use "User Enter Trigger" instead. Collision is for physics interactions.
What it does: Fires when a player picks up a Collectible GLB object.
Use cases:
Common pattern: Item Collected → Update Value (+1) → check if all items collected
What it does: Fires when player presses a specific keyboard key.
Configuration: Select which key to listen for (E, F, numbers, etc.)
Use cases:
Important: Only works when the Portals window has focus. If player clicks outside, key triggers won't fire.
What it does: Fires when player releases a keyboard key they were holding.
Use cases:
What it does: Fires when the player's health reaches zero.
Use cases:
Critical for games: This is how you detect kills. When Player A kills Player B, Player B's "Player Died" trigger fires. The killer is determined by which team the dead player was on (enemy team gets the point).
Example pattern:
Player Died →
Check which team player was on →
Award point to opposite team →
Respawn player after delay
What it does: Fires once when a player enters/loads into the space.
Use cases:
Critical: This is your "on game start" trigger for each player. Use it to set up everything the player needs.
Common pattern:
Player Login →
Set Player_Team = 0
Set Player_Health = 100
Show HUD iframe
What it does: Fires when a stationary player begins moving (WASD or joystick).
Use cases:
What it does: Fires when a moving player comes to a stop.
Use cases:
What it does: Fires when audio volume or track changes.
Use cases:
What it does: Fires when a countdown timer reaches zero.
Use cases:
Note: This is for the built-in Portals timer, not custom iframe timers.
Timer Limitation: Portals does not currently have a native shared timer system that can be synchronized across players and pulled into an iframe. For multiplayer timer displays, use local JavaScript timing in iframes triggered by game state changes. Timer values shown will be approximate and client-side.
What it does: Fires when a player walks into a Trigger Cube's volume.
This is one of the most important triggers. Use it for:
Configuration:
Important: Trigger cubes only activate when a player enters the cube (crosses the boundary from outside). If a player spawns or loads into the game already inside a trigger cube, the trigger will NOT fire. Plan spawn point placement accordingly, or use Player Login trigger for logic that must run when players join.
Example: Player walks into red team zone → User Enter Trigger fires → Set Player_Team = 1
What it does: Fires when a player leaves a Trigger Cube's volume.
Use cases:
Common pattern: Enter shows something, Exit hides it
User Enter → Show Token Swap UI
User Exit → Hide Token Swap UI
What it does: Fires whenever a specific variable changes value.
Configuration: Select which variable to watch
Use cases:
Critical for iframes: Use this to automatically send updated values to iframes whenever variables change.
Example pattern:
Value Updated (Red_Score) →
Send Message To Iframes: red_|Red_Score|
What it does: Fires when player unequips/removes a wearable item.
Use cases:
What it does: Fires when player equips a wearable item.
Use cases:
Effects are actions that happen when a task changes state. They're the "do this" part of your game logic.
What it does: Instantly moves the player to a named spawn point.
Configuration: Enter the exact spawn point name (case-sensitive)
Use cases:
Critical: Spawn point names are CASE-SENSITIVE. "RedSpawn1" is different from "redspawn1".
Example: After player joins red team → Teleport to "RedSpawn1"
What it does: Pushes the player in a direction with force.
Use cases:
What it does: Modifies how the player's camera behaves or looks.
Use cases:
What it does: Prevents or allows player from rotating their camera view.
Use cases:
What it does: Prevents or allows player from moving (WASD/joystick).
Use cases:
Warning: Always make sure to unlock movement eventually, or player gets stuck!
What it does: Switches between normal third-person and free-flying camera.
Use cases:
What it does: Makes an object invisible or visible.
Configuration: Select which object to hide/show
Use cases:
Example: Player collects all keys → Show Object (exit door)
What it does: Adjusts the fog density/color in the scene.
Use cases:
What it does: Changes lighting to simulate different times.
Use cases:
What it does: Adjusts the glow/bloom post-processing effect.
Use cases:
What it does: Sets or modifies the player's health value.
Configuration:
Use cases:
Critical for combat games: Use "Set → 100" after respawning to fully heal the player.
Example pattern:
RespawnRed task (on Active):
1. Teleport → RedSpawn1
2. Change Player Health → Set → 100
3. Reset task
What it does: Changes the player's 3D avatar/character model.
Use cases:
What it does: Prevents or allows player from changing their avatar.
Use cases:
What it does: Modifies player movement speed, jump height, etc.
Use cases:
What it does: Makes the player's avatar perform an animation.
Use cases:
What it does: Plays an audio file one time.
Use cases:
What it does: Continuously plays audio until stopped.
Use cases:
What it does: Mutes or unmutes audio.
Use cases:
What it does: Shows a brief popup message to the player.
Configuration: Enter the message text
Use cases:
Best practice: Keep messages short and clear. Players only see them briefly.
What it does: Shows a variable's value on screen.
Configuration: Select which variable to display
Use cases:
Note: For more control over display, use iframes instead.
What it does: Removes a displayed variable from screen.
What it does: Opens an external webpage inside the Portals window.
This is extremely powerful. Iframes let you:
See the IFRAMES section for complete documentation.
What it does: Closes an open iframe.
Use cases:
What it does: Sends data from Portals to all open iframes.
Configuration: Enter the message string. Use |variableName| to include variable values.
Built-in variables:
|username| - player's Portals ID or name as a quoted string|position| - all players' positions as an array keyed by username, e.g. {"buster"="(-32.49, 22.21, -99.82)"}CRITICAL: Do NOT use JSON with colons. Colons break the parser. Use underscore format instead.
Correct: score_|Red_Score| → sends "score_25"
Wrong: {"score": |Red_Score|} → BREAKS
Use cases:
What it does: Creates or modifies a variable.
Configuration:
Use cases:
Note: For complex logic, use Function Effect instead.
What it does: Executes NCalc expressions for advanced logic.
This is the most powerful effect. It lets you:
See the FUNCTION EFFECT section for complete documentation.
What it does: Submits a player's score to a leaderboard.
Configuration: Value Label must match the leaderboard's score label exactly.
Use cases:
What it does: Sets all tasks back to NotActive.
Use cases:
Warning: This resets EVERYTHING. Use carefully.
What it does: Manually fires another trigger.
Use cases:
What it does: Moves an object to a new position.
Use cases:
What it does: Creates a copy of an object.
Use cases:
What it does: Plays a built-in Portals animation on an object.
These effects only work on NPC objects, not regular 3D models.
What it does: Rotates the NPC to face the player.
Use cases:
What it does: Makes NPC walk to a specified location.
Use cases:
What it does: Changes which animation the NPC is playing.
Configuration: Animation name (must match GLB file exactly, case-sensitive)
Use cases:
What it does: Makes NPC visible or invisible.
Use cases:
What it does: Sends a command to an NPC's AI system.
Use cases:
Before writing a single task or effect, spend 10-15 minutes planning. This prevents 90% of debugging headaches.
Every game has a core loop. Write it in plain English first:
Example - Team Deathmatch:
1. Player joins a team (Red or Blue)
2. Player spawns at team base
3. Player kills enemy players
4. Team scores points for kills
5. First to 50 points OR time runs out → winner declared
6. Everyone returns to lobby
Example - Collectible Hunt:
1. Player enters the game area
2. Player finds and collects hidden items
3. Each item adds to their score
4. When all items collected → show victory
List every piece of data your game needs to track:
| Variable | Multiplayer? | Persistent? | Purpose |
|---|---|---|---|
Player_Team |
No | No | Which team this player is on (0=none, 1=red, 2=blue) |
Red_Score |
Yes | No | Red team's total points |
Blue_Score |
Yes | No | Blue team's total points |
Elapsed_Seconds |
Yes | No | Game timer |
Key questions:
Tasks are your game's state machine. List the major "events" or "states":
| Task | Type | What triggers it? | What does it do? |
|---|---|---|---|
JoinRed |
Single Player | Player enters red zone | Set team=1, teleport to red spawn |
JoinBlue |
Single Player | Player enters blue zone | Set team=2, teleport to blue spawn |
KillHandler |
Single Player | Player dies | Award point to enemy team, respawn |
RedWins |
Multiplayer | Red reaches 50 OR time runs out | Show victory, reset game |
BlueWins |
Multiplayer | Blue reaches 50 OR time runs out | Show victory, reset game |
Key insight: Single Player tasks = actions that affect only one player. Multiplayer tasks = state changes everyone sees.
Always build in this order:
DO NOT build everything then test. Build ONE task, test it works, then move to the next.
Testing checklist for each task:
Here's the complete implementation of a TDM game, step by step.
Create these in Variable Manager:
| Variable | Multiplayer | Persistent | Initial |
|---|---|---|---|
Player_Team |
No | No | 0 |
Red_Score |
Yes | No | 0 |
Blue_Score |
Yes | No | 0 |
Place and name these spawn points:
LobbySpawn - Where players startRedSpawn1, RedSpawn2, RedSpawn3 - Red team spawnsBlueSpawn1, BlueSpawn2, BlueSpawn3 - Blue team spawnsTask: JoinRed
Effects (on Active):
1. Function Effect - Set team (only if not already on a team):
if($N{Player_Team} == 0.0,
SetVariable('Player_Team', 1.0, 0.0),
0.0
)
2. Teleport → RedSpawn1
3. Notification Pill → "Joined Red Team"
4. Function Effect - Reset task:
SetTask('JoinRed', 'NotActive', 0.1)
Task: JoinBlue - Same pattern with Player_Team = 2.0 and BlueSpawn1
Test: Walk into red zone. Check:
Task: KillHandler
Effects (on Active):
1. Function Effect - Award point to enemy team:
if($N{Player_Team} == 1.0,
SetVariable('Blue_Score', $N{Blue_Score} + 1.0, 0.0),
if($N{Player_Team} == 2.0,
SetVariable('Red_Score', $N{Red_Score} + 1.0, 0.0),
0.0
)
)
2. Function Effect - Respawn after 3 seconds:
if($N{Player_Team} == 1.0,
SetTask('RespawnRed', 'Active', 3.0),
if($N{Player_Team} == 2.0,
SetTask('RespawnBlue', 'Active', 3.0),
0.0
)
)
3. Function Effect - Check for winner:
SetTask('CheckWinner', 'Active', 0.1)
4. Function Effect - Reset:
SetTask('KillHandler', 'NotActive', 0.1)
Task: RespawnRed
Effects (on Active):
1. Teleport → RedSpawn1
2. Change Player Health → Set → 100
3. Function Effect - Reset:
SetTask('RespawnRed', 'NotActive', 0.1)
Task: CheckWinner
Effects (on Active):
1. Function Effect - Check scores:
if($N{Red_Score} >= 50.0,
SetTask('RedWins', 'Active', 0.0),
if($N{Blue_Score} >= 50.0,
SetTask('BlueWins', 'Active', 0.0),
0.0
)
)
2. Function Effect - Reset:
SetTask('CheckWinner', 'NotActive', 0.1)
Task: RedWins
Effects (on Active):
1. Notification Pill → "RED TEAM WINS!"
2. Function Effect - Reset scores (2 sec delay):
SetVariable('Red_Score', 0.0, 2.0)
SetVariable('Blue_Score', 0.0, 2.0)
3. Function Effect - Reset player teams:
SetVariable('Player_Team', 0.0, 2.0)
4. Function Effect - Return to lobby:
SetTask('ReturnToLobby', 'Active', 2.0)
5. Function Effect - Reset self:
SetTask('RedWins', 'NotActive', 3.0)
| Variable | Multiplayer | Purpose |
|---|---|---|
Items_Collected |
No | How many items this player found |
Total_Items |
No | Total items to find (set on login) |
Effects:
1. SetVariable('Items_Collected', 0.0, 0.0)
2. SetVariable('Total_Items', 10.0, 0.0)
3. Notification Pill → "Find all 10 items!"
Effects:
1. Function Effect:
SetVariable('Items_Collected', $N{Items_Collected} + 1.0, 0.0)
2. Notification Pill → "+1 Item Found!"
3. Function Effect - Check if all found:
if($N{Items_Collected} >= $N{Total_Items},
SetTask('Victory', 'Active', 0.0),
0.0
)
When something doesn't work, follow this exact process:
Be precise about what's wrong:
Write out what SHOULD happen:
1. Player clicks door → Click trigger fires
2. Click trigger → DoorOpen task becomes Active
3. DoorOpen Active → Hide Object effect runs
4. Door becomes invisible
Then identify WHERE it breaks:
This is your most important debugging tool.
Access: Space Options > Task Debug Panel
What to check:
Pro tip: Keep Task Debug Panel open while testing. Watch task states change in real-time.
| Symptom | Likely Cause |
|---|---|
| Trigger doesn't fire | Wrong trigger type, object not selected, trigger cube too small |
| Task changes but no effect | Effect not added, wrong "on state" setting |
| Effect runs for wrong player | Task is Multiplayer when it should be Single Player |
| Variable not updating | Wrong variable name (case-sensitive), using wrong syntax |
| Function Effect does nothing | Missing "Trigger on Task Change" checkbox |
| Numbers look weird | Not using decimal notation (use 1.0 not 1) |
If you can't find the bug:
Causes:
Fix: Check which state triggers your effects. Open the task, look at "On Active", "On Completed", etc.
Causes:
Red_Score vs red_score)Fix:
Cause: That's what Multiplayer tasks do! When the task state changes, ALL players run the effects.
Fix: If only one player should be affected, use Single Player task instead.
Cause: Each player is running their own timer loop, all incrementing the same variable.
Fix: Use Single Player tasks for timer loops. Multiplayer tasks cause all players to run the loop simultaneously.
Note: Portals does not have a native shared timer system. For multiplayer timer displays, use local JavaScript timing in iframes triggered by game state changes.
Causes:
:) breaks the parserFix:
score_|Red_Score| not {"score": |Red_Score|}Cause: No check to prevent re-joining if already on a team.
Fix: Add condition before setting team:
if($N{Player_Team} == 0.0,
SetVariable('Player_Team', 1.0, 0.0),
0.0
)
Cause: Tasks don't auto-reset. Once they reach a state, they stay there.
Fix: Always add a reset effect:
SetTask('MyTask', 'NotActive', 0.1)
Causes:
Debug approach:
SetVariable('debug', 1.0, 0.0) - does it run at all?Cause: Effects on the same task run in order, but there's no guarantee of timing between different tasks.
Fix: Use delays to enforce order:
SetTask('Step1', 'Active', 0.0)
SetTask('Step2', 'Active', 0.5) // Runs 0.5 sec after Step1
SetTask('Step3', 'Active', 1.0) // Runs 1 sec after Step1
Cause: Multiplayer variables take 2-3 seconds to sync across all players.
Fix: Add delays before checking multiplayer variables:
// On Player Login, wait 3 seconds before checking shared state
SetTask('DelayedCheck', 'Active', 3.0)
When testing iframes:
Add console.log() statements to track what's happening:
PortalsSdk.setMessageListener(function(message) {
console.log('[Iframe] Received:', message); // See what Portals sends
// Your parsing code...
console.log('[Iframe] Parsed value:', parsedValue); // See what you extracted
});
Open your iframe URL directly in a browser to test:
Then test the Portals integration separately.
Add a visible debug element to your iframe:
<div id="debug" style="position:fixed;bottom:10px;left:10px;background:black;color:lime;padding:5px;font-family:monospace;font-size:12px;z-index:9999;">
Waiting for messages...
</div>
<script>
function debug(msg) {
document.getElementById('debug').textContent = msg;
console.log('[Debug]', msg);
}
PortalsSdk.setMessageListener(function(message) {
debug('Received: ' + JSON.stringify(message).substring(0, 50));
// ... rest of handler
});
</script>
Remove this before publishing!
This is the #1 source of iframe bugs. The syntax is DIFFERENT depending on direction:
Portals → Iframe (Send Message To Iframes effect):
score_|Red_Score|_|Blue_Score|
|variableName| for variables|username| (player ID/name), |position| (all players' positions)$N{variableName} - that's for Function Effects onlyIframe → Portals (JavaScript):
PortalsSdk.sendMessageToUnity(JSON.stringify({
TaskName: 'myTask',
TaskTargetState: 'SetNotActiveToActive'
}));
The Problem: When you activate an iframe AND send a message in the same task, the message arrives BEFORE the iframe finishes loading. The iframe never receives it.
Console log showing this bug:
[12:00:01] Sending message to iframes: scores_50_32
[12:00:01] Activating Iframe Event https://example.com/game-over.html
[12:00:02] [Iframe] Initialized - waiting for messages ← Too late!
The Solution: Ready Handshake Pattern
Task activates iframe with ?winner=red in URL
↓
Iframe loads, reads winner from URL
↓
Iframe sends: gameover_ready → Completed
↓
Portals function triggers on gameover_ready Completed
↓
Portals sends: scores_50_32
↓
Iframe receives scores message, displays result
GitHub Pages and browsers cache aggressively. Your changes won't appear without cache busting.
Add version parameter to ALL iframe URLs:
https://example.github.io/my-hud/index.html?v=1
Every time you update the iframe:
?v=2, ?v=3, etc.Pro tip: Use a high random number during development (?v=99847) so you don't have to track versions.
Messages from Portals can arrive in different formats. Your parsing code must handle all cases:
PortalsSdk.setMessageListener(function(message) {
console.log('[Iframe] Raw message:', message, typeof message);
let msg = message;
// Step 1: If it's a string, try to parse as JSON
if (typeof message === 'string') {
try {
msg = JSON.parse(message);
} catch (e) {
// Not JSON - that's OK, keep as string
// DO NOT RETURN HERE - underscore format won't be JSON
msg = message;
}
}
// Step 2: Convert to string for pattern matching
const msgStr = typeof msg === 'string' ? msg : JSON.stringify(msg);
// Step 3: Handle your message patterns
if (msgStr.startsWith('score_')) {
const value = parseFloat(msgStr.substring(6));
if (!isNaN(value)) {
updateScore(value);
}
} else if (msgStr.startsWith('time_')) {
const value = parseFloat(msgStr.substring(5));
if (!isNaN(value)) {
updateTimer(value);
}
}
// Add more patterns as needed
});
Critical mistakes to avoid:
return after JSON.parse fails - underscore format isn't JSON!isNaN() check after parseFloatIn multiplayer, players can join mid-game. Your iframe needs to handle this:
Problem: Player joins when score is 25-18 and timer is at 5:32. Their iframe shows 0-0 and 10:00.
Solution: Sync on Load
Option A: Request current state
// Iframe requests sync when loaded
PortalsSdk.sendMessageToUnity(JSON.stringify({
TaskName: 'request_sync',
TaskTargetState: 'SetNotActiveToActive'
}));
// Portals responds with full state: sync_332_25_18
Option B: Continuous updates
// Portals sends updates every second via Value Updated triggers
// Late joiner receives next update within 1 second
Iframes can be closed and reopened. They can refresh. Don't rely on iframe state.
Bad: Iframe tracks score internally, only receives increments
let score = 0;
// If iframe refreshes, score resets to 0 but game score is 25
Good: Iframe receives absolute values, displays what it's told
// Portals always sends current total: score_25
// Iframe just displays 25, no internal tracking needed
For HUD overlays, you want only your UI visible, not a white/colored background:
html, body {
background: transparent; /* Critical! */
margin: 0;
padding: 0;
overflow: hidden;
}
.hud-container {
/* Apply visible background only to your content */
background: rgba(0, 0, 0, 0.8);
border-radius: 8px;
padding: 10px;
}
The key: Keep html/body transparent, apply backgrounds only to content containers.
For HUDs: Size to match your content exactly
For Popups/Modals: Size to fit with padding
Position fields: Only fill in what you need, leave others BLANK (not 0)
Top Position: 0Bottom Position and Right PositionAccess: Open browser developer tools (F12) → Console tab while in Portals
The console shows everything happening in your space. Learning to read it is essential for debugging.
[TaskSystem] Task 'JoinRed' state changed: NotActive → Active
[TaskSystem] Trigger: User Enter Trigger on TriggerCube_RedZone
What this tells you:
Debugging use: Verify triggers are firing and tasks are changing state.
[EffectSystem] Executing effects for 'JoinRed' on Active
[EffectSystem] → Teleport to 'RedSpawn1'
[EffectSystem] → Notification: "Joined Red Team"
[EffectSystem] → Function Effect executed
What this tells you:
Debugging use: Verify effects are attached to the right state and running.
[FunctionEffect] Evaluating: if($N{Player_Team} == 0.0, SetVariable('Player_Team', 1.0, 0.0), 0.0)
[FunctionEffect] $N{Player_Team} = 0
[FunctionEffect] Condition true, executing: SetVariable('Player_Team', 1.0, 0.0)
[FunctionEffect] Result: Variable 'Player_Team' set to 1
What this tells you:
Debugging use: See why conditions pass or fail, verify variable values.
[VariableManager] Variable 'Red_Score' updated: 24 → 25
[VariableManager] Source: Function Effect in task 'KillHandler'
What this tells you:
Debugging use: Track variable changes, find unexpected modifications.
[IframeManager] Sending message to iframes: score_25
[IframeManager] Activating Iframe: https://example.com/hud.html?v=5
[IframeManager] Received from iframe: {"TaskName":"gameover_ready","TaskTargetState":"SetNotActiveToCompleted"}
What this tells you:
Debugging use: Verify message flow, check timing issues.
[ERROR] NCalc parse error in Function Effect: Unexpected token ':' at position 15
[ERROR] Task 'InvalidTask' not found
[ERROR] Spawn point 'RedSpawn1' not found (check case sensitivity)
[ERROR] Variable 'score' is undefined
What this tells you:
Debugging use: Direct pointer to what's broken.
[12:00:01.100] Sending message to iframes: gameover_red_50_32
[12:00:01.150] Activating Iframe: game-over.html
Problem: Message sent 50ms BEFORE iframe activated. Iframe won't receive it.
Fix: Use ready handshake pattern. Iframe signals when loaded, then receives message.
[12:00:01.000] Task 'GameTimer' state: NotActive → Active
[12:00:01.010] Task 'GameTimer' state: Active → NotActive
[12:00:01.020] Task 'GameTimer' state: NotActive → Active
[12:00:01.030] Task 'GameTimer' state: Active → NotActive
... (hundreds more)
Problem: Task is looping too fast, probably missing delay or wrong timing.
Fix: Check your reset and loop delays. Should be:
SetTask('GameTimer', 'NotActive', 0.9) // Reset
SetTask('GameTimer', 'Active', 1.0) // Loop (must be > reset delay)
[FunctionEffect] $N{Red_Score} = NaN
[FunctionEffect] Evaluating: $N{Red_Score} + 1.0
[FunctionEffect] Result: NaN
Problem: Variable was never initialized or was set to non-numeric value.
Fix: Initialize variables on Player Login:
SetVariable('Red_Score', 0.0, 0.0)
[Player1] KillHandler Active - awarding point to Blue
[Player2] KillHandler Active - awarding point to Blue
[Player3] KillHandler Active - awarding point to Blue
Problem: Multiplayer task running effects for everyone when only one player died.
Fix: KillHandler should be Single Player task, not Multiplayer.
[FunctionEffect] Evaluating: if($N{Player_Team} == 1, ...)
[FunctionEffect] $N{Player_Team} = 1.0
[FunctionEffect] Condition false
Problem: Comparing 1.0 to 1 (different types in some cases).
Fix: Always use decimal notation:
if($N{Player_Team} == 1.0, ...)
When debugging, add console.log() at key decision points:
PortalsSdk.setMessageListener(function(message) {
console.log('[RECV] Raw:', message);
console.log('[RECV] Type:', typeof message);
// ... parsing ...
console.log('[RECV] Parsed:', parsedValue);
console.log('[RECV] Action:', whatWeWillDo);
});
Make logs easy to filter:
[HUD] - HUD iframe logs[GameOver] - Game over screen logs[Timer] - Timer-related logs[RECV] - Received messages[SEND] - Sent messages[ERROR] - Error conditionsfunction updateScore(team, newScore) {
console.log(`[HUD] Score update: ${team} = ${newScore}`);
// ... update display ...
console.log(`[HUD] Display updated`);
}
Before publishing:
Turn any task into a trackable quest:
Quest States:
Quest Groups: Assign same group name to organize tasks together.
Rewards: Add wearables/collectibles granted on completion (requires Portals team assistance for item setup).
Manage all variables created with Update Value effect.
Location: Space Options → Variable Manager
Settings:
Defaults: Non-persistent, single-player
Initializing Variables: Variables do not have a built-in "default value" setting. To initialize variables, use a Player Login trigger with SetVariable effects:
SetVariable('Player_Score', 0.0, 0.0)
SetVariable('Player_Team', 0.0, 0.0)
Scripting system based on NCalc expression language.
| Syntax | Returns | Example |
|---|---|---|
$T{taskName} |
Task state as text | $T{door} → 'Active' |
$TN{taskName} |
Task state as number | $TN{door} → 1 |
$N{variableName} |
Variable value | $N{coins} → 50 |
$N{timerName} |
Timer elapsed time (seconds) | $N{RaceTimer} → 45.5 |
Task State Numbers: 0=NotActive, 1=Active, 2=Completed
Timer Values: You can read any running timer's elapsed time using $N{timerName}. The timer must be started first using the Start Timer effect.
SetTask(taskName, 'TaskState', delaySeconds)
SetTaskState(taskName, 'TaskState')
SetVariable(variableName, value, delaySeconds)
Examples:
SetTask('door', 'Active', 0.0) // Immediate
SetTask('alarm', 'NotActive', 5.0) // 5-second delay
SetTaskState('door', 'NotActive') // No delay parameter
SetVariable('coins', $N{coins} + 10, 0.0)
SetTask vs SetTaskState:
SetTask() - Has delay parameter, use for timed actionsSetTaskState() - No delay, use in reset functions triggered by task status changes| Operator | Example |
|---|---|
+ |
$N{coins} + 10 |
- |
$N{health} - 1 |
* |
$N{score} * 2 |
/ |
$N{time} / 2 |
% |
$N{coins} % 2 (remainder) |
** |
2 ** 3 (exponent = 8) |
| Operator | Example |
|---|---|
== |
$N{coins} == 10 |
!= |
$T{quest} != 'NotActive' |
> |
$N{coins} > 10 |
< |
$N{health} < 5 |
>= |
$N{coins} >= 10 |
<= |
$N{health} <= 0 |
| Operator | Name | Example |
|---|---|---|
&& |
AND | $T{task1} == 'Active' && $T{task2} == 'Completed' |
|| |
OR | $T{task1} == 'Completed' || $T{task2} == 'Completed' |
! |
NOT | !($T{alarm} == 'Active') |
if() - Single condition:
if(condition, whenTrue, whenFalse)
if($N{coins} >= 10,
SetVariable('doorUnlocked', 1, 0.0),
0
)
ifs() - Multiple conditions (first match wins):
ifs(
$N{health} <= 0, SetVariable('warning', 3, 0.0),
$N{health} <= 3, SetVariable('warning', 2, 0.0),
$N{health} <= 6, SetVariable('warning', 1, 0.0),
SetVariable('warning', 0, 0.0)
)
OnChange('taskName', 'TaskState') // Specific state
OnChange('taskName') // Any state change
OnChange('variableName', '>= 10') // Variable condition
Task completion triggers variable:
if(OnChange('puzzle1', 'Completed'),
SetVariable('doorUnlocked', 1.0, 0.0),
0.0)
Variable threshold completes task:
if(OnChange('coins', >= 10.0),
SetTask('buyDoor', 'Completed', 0.0),
0.0)
Multiple conditions with current state check:
(OnChange('task1', 'Active') || OnChange('task2', 'Completed'))
&& $T{task1} == 'Active'
&& $T{task2} == 'Completed'
State change with conditional actions:
if(OnChange('questStep'),
ifs($T{questStep} == 'NotActive', SetVariable('hintText', 0.0, 0.0),
$T{questStep} == 'Active', SetVariable('hintText', 1.0, 0.0),
SetVariable('hintText', 2.0, 0.0)),
0.0)
SelectRandom(item1, item2, item3, ...)
Random number reward:
SetVariable('coins', $N{coins} + SelectRandom(1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0), 0.0)
50/50 chance:
SelectRandom(true, false)
Random task state:
SetTask('alarm', SelectRandom('NotActive', 'Active', 'Completed'), 0.0)
| Function | Description | Example |
|---|---|---|
Min(a, b) |
Returns smaller of two numbers | Min($N{coins}, 100.0) → cap at 100 |
Max(a, b) |
Returns larger of two numbers | Max($N{health}, 0.0) → prevent negative |
Round(number) |
Rounds to nearest whole | Round(3.6) → 4 |
Abs(number) |
Absolute value | Abs(-5) → 5 |
Floor(number) |
Rounds down | Floor(3.9) → 3 |
Ceiling(number) |
Rounds up | Ceiling(3.1) → 4 |
Sqrt(number) |
Square root | Sqrt(9.0) → 3 |
Nesting functions:
Min(Max($N{health}, 0.0), 100.0) // Clamp health between 0-100
Functions for working with multiple players simultaneously. Essential for team games, role assignment, and any logic affecting multiple players.
| Parameter | Type | Description |
|---|---|---|
[Players] |
List | All players currently in the room |
Built-in player properties:
playerName - The player's usernamehealth - The player's health (default: 100, no max limit)Picks random players from a list. Selection is deterministic (all clients get same result) and persistent (survives reconnects).
SelectRandomPlayers([Players], 2) // Pick 2 random players
Gets a parameter value from each player in a list.
SelectPlayersParameters([Players], 'health') // Returns [100, 85, 100, 50]
SelectPlayersParameters(SelectRandomPlayers([Players], 3), 'playerName') // Names of 3 random players
Sets a parameter on all players in a list. Changes sync to all clients automatically.
SetPlayersParameters([Players], 'canMove', true) // Enable movement for everyone
Prints a value to the browser console for debugging.
PrintString(SelectPlayersParameters([Players], 'playerName')) // Debug: list all player names
Impostor Assignment (Among Us style):
SetPlayersParameters([Players], 'impostor', false) +
SetPlayersParameters(SelectRandomPlayers([Players], 2), 'impostor', true)
Sets everyone to non-impostor, then picks 2 random impostors. Use on game start trigger.
Team Assignment (Red vs Blue):
First track player count with a multiplayer variable incremented when players enter a "ready" zone.
SetPlayersParameters([Players], 'team', 'blue') +
SetPlayersParameters(SelectRandomPlayers([Players], Floor($N{PlayerCount} / 2.0)), 'team', 'red')
Sets everyone to blue, then switches half to red. Guarantees all players are assigned and teams are balanced.
One Hunter, Everyone Else Hides:
SetPlayersParameters([Players], 'role', 'hider') +
SetPlayersParameters(SelectRandomPlayers([Players], 1), 'role', 'hunter')
Damage All Players:
SetPlayersParameters([Players], 'health', 50) // Set everyone's health to 50
+, later operations can override earlier onesYou can read timer values as variables and manipulate them in calculations.
Reading timer values:
$N{RaceTimer} // Returns elapsed seconds (e.g., 45.5)
$N{RaceTimer} >= 60.0 // Check if timer is 60+ seconds
Manipulating timer values:
SetVariable('HalfTime', $N{RaceTimer} / 2.0, 0.0) // Divide time by 2
SetVariable('BonusTime', $N{RaceTimer} + 30.0, 0.0) // Add 30 seconds
SetVariable('TimeRemaining', 120.0 - $N{RaceTimer}, 0.0) // Calculate remaining time
SetVariable('TimeScore', 1000.0 - ($N{RaceTimer} * 10.0), 0.0) // Time-based scoring
Common patterns:
Checkpoint time storage:
SetVariable('Checkpoint1Time', $N{RaceTimer}, 0.0)
Time-based scoring (faster = more points):
SetVariable('Score', Max(1000.0 - ($N{RaceTimer} * 5.0), 0.0), 0.0)
Time penalty on death:
SetVariable('PenaltyTime', $N{GameTimer} + 10.0, 0.0)
Note: Timer must be started (Start Timer effect) before reading. Unstarted timers return 0.
0.0, 1.0, 50.0 (not 0, 1, 50) to avoid type errors| Task Type | Behavior |
|---|---|
| Single Player | Effects run only for the player who triggered it |
| Multiplayer | When task state changes, effects run for ALL players |
Critical: For looping tasks (timers, game loops), use Single Player tasks. Multiplayer tasks will cause all players to run the loop, causing acceleration/duplication bugs.
Multiplayer variables take 2-3 seconds to sync across all players. This causes race conditions when:
Solution: Add delays before checking multiplayer variables:
// On Player Login, delay 3 seconds before checking shared state
SetTask('DelayedCheck', 'Active', 3.0)
For continuous updates (use Single Player tasks to prevent acceleration):
// GameLoop task (Single Player, on Active):
// Effect 1: Your game logic here
SetVariable('SomeValue', $N{SomeValue} + 1.0, 0.0)
// Effect 2: Reset for next loop
SetTask('GameLoop', 'NotActive', 0.9)
// Effect 3: Loop
SetTask('GameLoop', 'Active', 1.0)
Note on Timers: Portals does not have a native shared timer system that syncs across players. For multiplayer timer displays, use local JavaScript timing in iframes triggered by game state changes.
| Setting | Description |
|---|---|
| Value Label | Must match the Leaderboard Score Label (e.g., "score", "coins") |
Compatibility:
Note: For time-based leaderboards, the timer auto-posts when it ends.
Embed external web pages with bidirectional communication.
Iframe is an effect, not a building tool. To display an iframe:
| Setting | Description |
|---|---|
| Iframe URL | URL to the web page |
| Layer Order | Z-index for stacking (higher = on top) |
| Left Position (px) | Distance from left edge |
| Right Position (px) | Distance from right edge |
| Top Position (px) | Distance from top edge |
| Bottom Position (px) | Distance from bottom edge |
| Width (px) | Iframe width in pixels |
| Height (px) | Iframe height in pixels |
| NPC will animate | Toggle mouth animation for NPC dialogues |
Important: Only fill in position/size values you want to change. Leave other fields blank - don't enter zeros for fields you don't need.
For top-centered HUD:
Top Position: 0justify-content: centerFor bottom-right popup:
Bottom Position and Right PositionFor fixed-size centered iframe:
(screen width - iframe width) / 2For HUD overlays where only the content should be visible (no background):
body {
background: transparent;
}
/* Wrap content in a container with the visible background */
.hud-container {
display: flex;
background: rgba(0,0,0,0.9);
border-radius: 8px;
}
Key principle: Keep body/html transparent, apply backgrounds only to content containers. Size the iframe to match content exactly.
Add to your HTML:
<script src="https://portals-labs.github.io/portals-sdk/portals-sdk.js?v=10005456"></script>
// MUST stringify the JSON object
PortalsSdk.sendMessageToUnity(JSON.stringify({
TaskName: "level1_intro",
TaskTargetState: "SetNotActiveToActive"
}));
Valid TaskTargetState values:
ToNotActiveSetNotActiveToActiveSetActiveToCompletedSetCompletedToActiveSetAnyToCompletedSetAnyToActiveSetActiveToNotActiveSetCompletedToNotActiveSetNotActiveToCompletedUse the SDK's message listener to receive commands from Portals:
// Register the listener
PortalsSdk.setMessageListener(function(message) {
console.log('Received:', message);
let data = message;
// Parse if string - but DON'T return on failure (might be underscore format)
if (typeof message === 'string') {
try {
data = JSON.parse(message);
} catch (e) {
// Not JSON - keep as string for underscore format parsing
data = message;
}
}
const msg = typeof data === 'string' ? data : JSON.stringify(data);
// Handle underscore format (recommended)
if (msg.startsWith('score_')) {
const score = parseFloat(msg.substring(6));
if (!isNaN(score)) updateScore(score);
} else if (msg.startsWith('time_')) {
const elapsed = parseFloat(msg.substring(5));
if (!isNaN(elapsed)) updateTimer(elapsed);
}
});
Sending from Portals:
CRITICAL: JSON with colons (:) breaks Portals' NCalc parser. Use underscore format instead:
| Data | Message Format | Effect Setting |
|---|---|---|
| Score | score_25 |
score_|Score| |
| Time | time_120 |
time_|Elapsed_Seconds| |
| Team scores | sync_120_25_30 |
sync_|Time|_|Red|_|Blue| |
Note: In "Send Message To Iframes" effect, use |variableName| (pipe syntax), NOT $N{variableName}.
Tip: Use Value Updated trigger on variables to automatically send iframe updates when values change.
// Must be inside user event handler (onclick)
PortalsSdk.closeIframe();
For screens that display results (winner, scores), use a two-step handshake to ensure the iframe is loaded before receiving data:
Problem: Portals sends messages before iframe finishes loading, so data is lost.
Solution:
Step 1: Create iframe URLs with winner in params
game-over.html?winner=red&v=1
game-over.html?winner=blue&v=1
game-over.html?winner=tie&v=1
Step 2: Iframe signals ready on load
// In iframe, after DOM loaded:
PortalsSdk.sendMessageToUnity(JSON.stringify({
TaskName: 'gameover_ready',
TaskTargetState: 'SetNotActiveToCompleted'
}));
Step 3: Portals function sends scores on ready
gameover_ready status is Completedscores_|Red_Score|_|Blue_Score|Step 4: Iframe reads URL params + listens for scores
// Read winner from URL (available immediately)
const params = new URLSearchParams(window.location.search);
const winner = params.get('winner');
// Listen for scores message
PortalsSdk.setMessageListener(function(msg) {
if (msg.startsWith('scores_')) {
const parts = msg.split('_');
const redScore = parts[1];
const blueScore = parts[2];
displayGameOver(winner, redScore, blueScore);
}
});
Note: Bump the v= version number when updating iframe to bust cache.
| Parameter | Effect |
|---|---|
?noCloseBtn=true |
Hide close button |
?hideMaximizeButton=true |
Hide maximize |
?hideRefreshButton=true |
Hide refresh |
?maximized=true |
Open fullscreen |
?forceClose=true |
X closes instead of minimize |
Example:
https://example.com/page.html?noCloseBtn=true&maximized=true
| Issue | Cause | Fix |
|---|---|---|
| "[object Object] is not supported" | Sending raw object | Use JSON.stringify() |
| "Failed to launch uniwebview" | Testing in browser | Test in Unity WebView |
| "User gesture required" | closeIframe outside click | Wrap in onclick handler |
| Iframe not receiving messages | JSON with colons | Use underscore format: score_|Score| |
| Timer not updating display | Early return in JS | Don't return after JSON.parse failure |
| Timer accelerates with 2+ players | All players incrementing | Use Single Player task for loops |
| Variables not syncing | Race condition | Add 3-second delay before checking |
| Iframe misses initial message | Message sent before load | Use ready handshake pattern (see Game Over Pattern) |
| URL params show literal |Var| | Portals doesn't interpolate URL params | Pass static values in URL, dynamic via message |
Buy Zone Setup:
Sell Zone: Same setup with Buy=Off, Sell=On
NotActive → Active → Completed
↑ ↓ ↓
←─────────←─────────←
(Any state can transition to any other)
Door that opens on click, closes after exit:
Collectible counter:
Multi-step quest:
Auto-reset task (resets immediately after firing): Create a separate reset function for each task:
Active (or Completed)SetTaskState('taskName', 'NotActive')This pattern is useful for:
Example reset functions:
// Function: reset_red_wins
// Trigger: timer_red_wins status is Active
// Action: SetTaskState('timer_red_wins', 'NotActive')
// Function: reset_gameover_ready
// Trigger: gameover_ready status is Completed
// Action: SetTaskState('gameover_ready', 'NotActive')
All games follow: Player Action → Feedback → Reward → Repeat
Spend 10-15 minutes designing before building to prevent confusion and edge cases.
| Pattern | Best For | Complexity |
|---|---|---|
| Collectible | Treasure hunts, coin collection, exploration | Beginner |
| Puzzle | Escape rooms, logic challenges, hidden objects | Intermediate |
| Quest/RPG | Story-driven, NPC interactions, progression | Intermediate |
| Racing | Time trials, obstacle courses, leaderboards | Beginner |
10.0 not 10$T{Open Door} works, $T{open door} doesn't'Active' not "Active"if() instead due to known bugs