Gotchas¶
Hard-won knowledge from shipping Portal modes. Each entry here cost real time to figure out the first time. Save your future self some hours.
File format & encoding¶
CRLF line endings are required¶
CRLF, not LF
Portal's TypeScript parser expects CRLF (\r\n) line endings. Files saved with LF (\n) — the default on macOS and Linux editors — get rejected or parsed incorrectly. The error messages from the editor are misleading: they often look like syntax errors on lines that look fine.
Symptoms:
- Mode fails to load with vague parse errors
- Errors point at lines that are syntactically correct
- Pasting code from a fresh download "fixes" it temporarily until you save again
Fix:
- Configure your editor to save with CRLF.
- For VS Code: bottom-right line-endings indicator → CRLF.
- Add a
.gitattributesto the repo to force CRLF on.tsfiles:*.ts text eol=crlf - When automating file edits with Python or shell tooling, read and write in binary mode to preserve CRLF. See Godot Workflow.
Smart punctuation crashes the parser¶
ASCII only
Curly quotes (", ", ', '), em-dashes (—), en-dashes (–), ellipsis (…), and other smart-punctuation characters in TypeScript source files crash the Portal parser. Editors that auto-correct quotes (Pages, Word, default macOS notes) silently insert these.
Symptoms:
- File looks syntactically correct
- Parser errors point at character positions that look like normal quotes
- Copy-paste from an external doc breaks the file
Fix:
- Use a code editor (VS Code, Cursor, Rider, Nova) for all
.tsediting — they don't auto-substitute. - If you have to paste from a doc, paste through a plain-text intermediate first.
- Add a pre-commit grep to catch these:
bash grep -P '[\u2018\u2019\u201C\u201D\u2013\u2014\u2026]' src/*.ts && exit 1
Console output¶
console.log doesn't work on PS5¶
No console.log on PS5
console.log (and console.error, console.warn) work in the PC test environment but are silently dropped on PS5. Modes that rely on console output for debugging will look broken on console even if they work on PC.
Implication:
- You cannot use
console.logfor production telemetry or debugging that needs to work on PS5. - Use in-world UI (banner messages, scoreboard columns, world text) for any state you need to see during console testing.
Workaround pattern:
ts
// Wrap a debug-banner helper that conditionally compiles to no-op
const DEBUG = false;
function debug(msg: string) {
if (DEBUG) {
// banner notification or world text — works on PS5
ShowBanner(msg);
}
}
Scoreboard¶
SetScoreboardPlayerValues hard caps at 6 arguments¶
Six columns max, hard fail at runtime
The Portal API SetScoreboardPlayerValues accepts at most 6 values. Passing 7 throws at runtime, not at parse time. TypeScript types don't catch this. The error surfaces only when the mode goes live.
Symptoms:
- Mode crashes when the scoreboard is rendered
- Error references
SetScoreboardPlayerValuesargument count - The crash happens at first scoreboard update, not at load
Plan column layouts under this constraint from the start. If you need more data, fold related stats into a single column (combined string like "3K / 1HVT") rather than splitting.
WorldIcon¶
SetWorldIconOwner must come first¶
Owner before everything else
After creating or repurposing a WorldIcon, SetWorldIconOwner must be the first call. Calling SetWorldIconPosition, SetWorldIconText, SetWorldIconColor, or any other setter before SetWorldIconOwner results in the icon attaching to the wrong owner — or no owner — and silently rendering incorrectly (or not at all).
Correct pattern:
ts
const icon = CreateWorldIcon(...);
SetWorldIconOwner(icon, targetPlayer); // FIRST
SetWorldIconPosition(icon, position);
SetWorldIconText(icon, "HVT");
SetWorldIconColor(icon, RED);
Symptoms when violated:
- Icon renders for the wrong player (or all players)
- Icon doesn't render at all
- Icon orphans visually after the intended owner moves
AreaTrigger¶
AreaTrigger reliability on PS5¶
AreaTrigger is unreliable on PS5
AreaTrigger volumes have been observed to fire inconsistently on PS5 — sometimes missing entries, sometimes firing late, sometimes not at all. The PC test environment doesn't reliably reproduce the issue.
Affected use cases:
- Spawn protection volumes (player walks into trigger, gets invuln)
- Capture point detection
- Any "is player inside this region" check that needs to be authoritative
Workaround: AABB (axis-aligned bounding box) check in script, evaluated each tick:
```ts function isInsideAabb(pos: Vector, min: Vector, max: Vector): boolean { return pos.X >= min.X && pos.X <= max.X && pos.Y >= min.Y && pos.Y <= max.Y && pos.Z >= min.Z && pos.Z <= max.Z; }
// On each tick / position update: if (isInsideAabb(player.Position, spawnZoneMin, spawnZoneMax)) { applySpawnProtection(player); } ```
This costs a per-tick computation but is deterministic across platforms.
Duplicate ObjIds break AreaTrigger lookups silently¶
Duplicate ObjIds silently break the spatial JSON
Two AreaTrigger (or any node type) entries with the same ObjId in a spatial JSON file → the Portal runtime resolves the lookup to one or the other, non-deterministically. No error is logged. The mode just behaves as if half its triggers don't exist.
How this happens:
- Duplicating a node in Godot copies the ObjId
- Hand-editing JSON and forgetting to renumber
- Merging spatial JSON files
Detection:
- Run the
convert_ctf_spatial.pyscript — it detects duplicates and renumbers. - For ad-hoc check:
bash jq '[.objects[].ObjId] | group_by(.) | map(select(length > 1))' spatial.json
Vector components¶
mod.Vector requires explicit component names¶
X/Y/Z don't work; XComponentOf does
The Portal mod.Vector API doesn't accept positional (x, y, z) constructors and doesn't expose .X, .Y, .Z directly the way you'd expect. You read components via XComponentOf(v) / YComponentOf(v) / ZComponentOf(v).
Pattern:
```ts const pos = player.Position; const x = XComponentOf(pos); const y = YComponentOf(pos); const z = ZComponentOf(pos);
// Construction (verify against current API) const v = mod.Vector(x, y, z); // or whatever the constructor pattern is ```
If your IDE autocompletes pos.X and the code "looks right" but doesn't work, this is the cause.
mod.GetSpatialObject limitations¶
Doesn't return positions for AreaTrigger or InteractPoint¶
mod.GetSpatialObject() works for visible props (ComputerMonitor, etc.) but doesn't retrieve positions for AreaTrigger or InteractPoint node types. Even if those nodes have valid positions in the spatial JSON, the API returns null/undefined for the position.
Workaround:
- Author a visible prop (like
ComputerMonitor) at the desired location in Godot. - Read its position at runtime via
GetSpatialObject. - Spawn the
InteractPoint/AreaTriggerprogrammatically from that position with a calculated offset.
This is the pattern used by CTF team switch stations.
Vehicle handling¶
Seat events spam errors with null seat indices¶
The vehicle seat-event handler occasionally fires with null/undefined seat indices. If unguarded, this spams errors to the console (or whatever telemetry path is active) and can hide real errors.
Fix:
ts
function onSeatChange(player, vehicle, seatIndex) {
if (seatIndex == null) return; // guard early
// ... rest of handler
}
Splash screens¶
Splash screen indefinite display¶
There's been a reported issue with splash screens not dismissing reliably on round transition. If your mode shows a splash on round start and it persists past the transition, this may be the same bug. Verify whether reproducible in current Portal builds.
Workaround if you hit it: explicit hide call on every state transition, even when the state machine "shouldn't" need it.
Quick reference card¶
| Don't do | Do |
|---|---|
| Save with LF line endings | CRLF (\r\n) |
| Use smart quotes / em-dashes | ASCII only in .ts files |
Rely on console.log for PS5 debugging |
In-world UI |
Pass 7+ values to SetScoreboardPlayerValues |
Plan for 6 columns |
Set WorldIcon properties before SetWorldIconOwner |
Owner first, always |
Trust AreaTrigger on PS5 |
AABB check in script |
| Duplicate ObjIds across nodes | Run the conversion script |
Access vec.X directly |
XComponentOf(vec) |
GetSpatialObject on AreaTrigger / InteractPoint |
Spawn from a visible prop's position |