Design¶
Single-flag CTF: one neutral flag at center; either team can pick it up; carrier returns it to their own base for a capture. After a capture, a cooldown holds the flag at base before it respawns at center.
Flag state machine¶
stateDiagram-v2
[*] --> AtCenter
AtCenter --> Carried : player picks up
Carried --> Dropped : carrier killed or<br/>force-drop
Carried --> Captured : carrier reaches own base
Dropped --> Carried : another player picks up
Dropped --> AtCenter : drop timer elapses<br/>(auto-return)
Captured --> Cooldown : score awarded,<br/>announcement
Cooldown --> AtCenter : cooldown timer elapses
State details¶
| State | Behavior |
|---|---|
AtCenter |
Flag idle at the map center. Visible on minimap via the buried CapturePoint marker. Either team can pick up. |
Carried |
A player is holding the flag. Their position is marked. |
Dropped |
Flag was dropped on death or force-drop. Auto-returns to center after a timer if not picked up. |
Captured |
Flag was returned to a team's base. Score awarded; in-world flag returns to a fixed location at the capturing team's base. |
Cooldown |
Brief pause after capture so the round doesn't immediately start a new run. |
Capture flow¶
- Player on Team A picks up the flag at center.
- Their position is announced ("Player picked up the flag").
- They run to Team A's base.
- On reaching the capture trigger at base → score for Team A.
- Capture announcement names the player ("Player captured the flag for Team A!").
- State →
Cooldown. Flag visually rests at Team A's base. - Cooldown elapses → state →
AtCenter. Flag respawns at the map's centerpoint.
Player name announcements¶
Added in versions 4.5.9 through 4.5.11. The announcement system uses the player's display name in the in-world chat / banner notifications:
- Pickup: "PlayerName picked up the flag"
- Drop: "PlayerName dropped the flag"
- Capture: "PlayerName captured the flag for Team"
These names come from the standard player object — no manual lookup is needed against an external data source.
Drop position fix¶
Earlier builds had a bug where the dropped flag would spawn at an incorrect position (often falling through the world or appearing inside geometry). The fix uses the carrier's last-known valid ground position rather than their literal Position at moment of death — death animations and ragdolls move the player object out of the navigable space.
Buried CapturePoint minimap marker¶
Discovered via Mike on the BFPortal Discord
Getting the flag's location to show up as a marker on the minimap without using a true CapturePoint object is non-obvious. The trick: spawn a CapturePoint underground (Y far below the map floor) with no actual capture mechanics wired up. The Portal runtime renders the minimap marker based on the CapturePoint's existence, but since it's buried underground, players never interact with it.
The actual gameplay capture trigger is a separate AreaTrigger at the real flag location.
This is a workaround — Portal doesn't expose a "minimap marker, please" primitive. The buried CapturePoint is the cleanest way to get the visual without the gameplay implications.
Scoring¶
| Event | Effect |
|---|---|
| Successful capture | +1 to capturing team's score |
| Successful capture | +1 to capturing player's captures |
| Kill while not carrying or having recently carried | +1 to killer's kills |
| Death | +1 to victim's deaths |
Earlier builds tracked flag_carrier_kills separately (kills made against a flag carrier or by a flag carrier). That field was merged into the regular kills counter when the scoreboard was simplified to fit under the 6-argument cap — see Per-player scoreboard below.
Win condition: first team to N captures, or capture lead at match timer expiry.
Carrier rules¶
Carrying the flag comes with significant restrictions. These are enforced in script, not via Portal Builder modifiers:
- Melee only — the carrier is forced to the sledgehammer. No firearms, no gadgets. Swing or die.
- Drop on weapon switch — attempting to switch back to a primary drops the flag immediately.
- Vehicle passenger only — a flag carrier entering a vehicle is forced into a passenger seat. No solo flag runs in a tank.
- Globally marked — colored smoke trails the carrier; they're spotted on the minimap for everyone; a flag icon floats above their head.
These rules turn flag runs into team plays — the carrier needs cover, transport, and route-calling from squadmates because they can't fight back effectively on their own.
Drop and return¶
When a carrier dies or voluntarily drops:
- The flag is thrown along a projectile arc based on the carrier's look direction and movement velocity at the moment of drop. It's not just placed at their feet.
- Full collision detection against walls and terrain — the flag lands where physics says.
- A 5-second pickup delay holds the flag in place after a drop. During this delay, smoke is hidden and the world icon shows "UNLOCKING" (matching the post-capture cooldown UX).
- Teammates can return a dropped friendly flag by interacting with it; enemies can pick it up after the delay.
- If nobody touches the dropped flag for 30 seconds, it auto-returns to its home position.
Post-capture cooldown¶
After a successful capture, the flag respawns at home but enters a 15-second locked cooldown before it can be picked up again. During the cooldown:
- Smoke is suppressed (so smoke-on continues to be a reliable "flag is grabbable" signal)
- The world icon label shows "UNLOCKING"
- After 15 seconds the smoke and "CAPTURE" label are restored
This gives both teams a reset window after each score — preventing a single dominant carrier from chaining captures with no breathing room for the defending team.
HUD elements¶
A custom HUD sits at the top of the screen:
- Team scores with bracket indicators highlighting the leading team
- Round timer counting down minutes:seconds
- Flag progress bar — real-time indicator showing how far each flag has moved from its home base toward the enemy capture zone, with smooth animated icons
- Team orders bar — context-aware updates ("Friendly flag taken", "Enemy flag dropped", "Enemy flag captured") so players always know the current flag state
Voice callouts¶
In-game VO announcements fire on:
- Flag taken
- Flag returned (manually or auto)
- Flag captured
These reuse BF6's built-in objective announcer voice lines.
Auto team balance¶
Teams are automatically rebalanced if one side gains too large an advantage. The most recently joined player on the larger team is moved after a countdown warning (giving them a moment to react).
Multi-team support¶
The script supports up to 7 teams for larger CTF variants, with per-team flag colors, capture zones, and scoring. The standard 189th deployment is 2-team, but the multi-team scoreboard layout exists in code (4 columns: Team / Captures / Kills / Deaths).
Per-player scoreboard¶
CTF uses a custom scoreboard via SetScoreboardPlayerValues. The same 6-argument hard cap applies — see Portal Scripting Gotchas. With the player handle taking the first slot, that leaves 5 data columns.
History is worth noting: an earlier build attempted Captures / Kills / Deaths / Assists / Carrier Kills (5 data columns), which was technically at the cap but caused trailing-zero columns to render in unfilled multi-team variants. Subsequent builds dropped down to 3 columns and merged Carrier Kills into Kills:
| # | Column | Source field on PlayerScore |
|---|---|---|
| 1 | Captures | captures |
| 2 | Kills | kills (carrier kills no longer separated; all kills go here) |
| 3 | Deaths | deaths |
The 3-team and FFA variants use a 4-column layout, prepending a Team column:
| # | Column |
|---|---|
| 1 | Team |
| 2 | Captures |
| 3 | Kills |
| 4 | Deaths |
Assists and Carrier Kills were dropped to stay safely under the cap. If you need to reintroduce one, drop another to keep the data-column count at 3 (2-team) or 4 (multi-team).
String keys¶
json
"scoreboard_captures_label": "Captures",
"scoreboard_kills_label": "Kills",
"scoreboard_deaths_label": "Deaths",
"scoreboard_capture_assists_label": "Assists", // unused in current build
"scoreboard_carrier_kills_label": "Carrier Kills" // unused in current build
The latter two keys remain in strings.json for ease of reintroduction.
Clan info embed¶
Round preamble shows a banner with:
- 189th name / branding
- Discord invite URL
- Brief mode description
This is rendered as an in-world UI panel during the pre-round phase. The Discord invite URL is hardcoded in the script (rotate manually when invites are regenerated).
Spawn protection¶
After respawn, players have a brief invulnerability window. Earlier builds used AreaTrigger-based spawn protection (a trigger volume around each spawn point), but AreaTrigger reliability on PS5 turned out to be inconsistent — see Portal Scripting Gotchas. Current builds use an AABB (axis-aligned bounding box) check against player position in script, evaluated each tick.
Vehicle handling¶
Earlier builds had a bug where vehicle seat events spammed errors to the console. Fixed by guarding the seat-event handler against null/undefined seat indices that the runtime occasionally emits. See API Quirks.
Map-specific notes¶
| Map | Notes |
|---|---|
MP_Abbasid |
First map shipped. Used as reference for layout conventions. |
MP_Hagental |
The canonical "working reference" build. When a new map breaks, the standard fix is to diff its spatial JSON against Hagental's — they should be structurally identical. |
MP_Outskirts |
Asymmetric map; the hardest to balance. After multiple iterations the flag sits at approximately X=-30, Z=-98 with adjusted HQ Forward spawn positions. Final imbalance: ~3% on opening rush, ~13% on HQ Forward rush — near the practical balance ceiling given the map's geometry. Known landmine: an early build's spatial JSON used Laptop_01 for the team-switch station prop instead of ComputerMonitor_01, which silently broke station rendering. Always verify ObjId 503/504 are ComputerMonitor_01. |
MP_Subsurface |
Working reference, structurally identical to Hagental's spatial JSON. |
Each map's spatial JSON lives in the repo under a per-map folder. The TypeScript loader reads the active map's JSON at runtime and configures the mode accordingly.
Diffing a broken map against a known-good one¶
The fastest way to debug a new map is to diff its spatial JSON against a working reference (typically Hagental or Subsurface):
```bash python3 -c " import json with open('MP_Subsurface_Single_CTF_spatial.json') as f: ref = json.load(f) with open('MP_NewMap_Single_CTF_spatial.json') as f: new = json.load(f)
Compare object inventory by ObjId, type, id_path¶
" ```
In practice the difference is usually one or two object types or names — a renamed prop, a missed ObjId. The structure (object count, ObjId space, hierarchy) should match the reference exactly.