The VScript Book

Chapter 5.6: Seeing Everything - TracePlus

Raycasting—shooting an invisible line to detect what it hits—is fundamental to interactive game logic. Need to know what the player is looking at? Raycast. Want a laser turret to detect targets? Raycast. Vanilla VScript's TraceLine function exists, but it has a fatal flaw: it only sees world geometry. It's completely blind to entities. PCapture-Lib's TracePlus fixes this, and adds portal support.

raycast

Raycasting is often used for weapon implementation to find out where the player has hit

The Problem: TraceLine is Broken

Let's say you want to create a security laser that detects when the player blocks it.

// Vanilla VScript attempt
function CheckLaserBeam() {
    local startPos = laserEmitter.GetOrigin()
    local endPos = laserReceiver.GetOrigin()
    
    local fraction = TraceLine(startPos, endPos, null)
    
    if (fraction < 1.0) {
        printl("Laser blocked!")
    }
    return 0.1  // Think loop
}

This code will only detect walls. If the player, a cube, or any other entity blocks the laser, TraceLine returns 1.0 (no hit). The laser is useless for gameplay.

Critical Limitation of TraceLine: The standard TraceLine(startPos, endPos, ignoreEntity) can only detect world geometry (walls, floors, ceilings). It completely ignores all entities: players, props, NPCs, cubes, turrets—everything. This makes it almost useless for interactive game mechanics.

The Solution: TracePlus.Bbox

TracePlus provides multiple trace functions that actually work with entities.

Basic Entity Detection

// With PCapture-Lib: Actually detects entities
function CheckLaserBeam() {
    local startPos = laserEmitter.GetOrigin()
    local endPos = laserReceiver.GetOrigin()
    
    local trace = TracePlus.Bbox(startPos, endPos)
    
    if (trace.DidHit()) {
        local hitEntity = trace.GetEntity()
        
        if (hitEntity != null) {
            printl("Laser blocked by: " + hitEntity.GetClassname())
            
            if (hitEntity.IsPlayer()) {
                TriggerAlarm()
            }
        } else {
            printl("Laser blocked by world geometry")
        }
    }
    
    return 0.1
}

Understanding the Result

All TracePlus functions return a result object with useful methods:

local trace = TracePlus.Bbox(startPos, endPos)

// Did we hit anything?
if (trace.DidHit()) {
    // Get the exact hit position
    local hitPos = trace.GetHitPos()
    
    // Get what we hit
    local hitEntity = trace.GetEntity()  // null if world geometry
    
    // Check if we hit world vs entity
    if (trace.DidHitWorld()) {
        printl("Hit a wall")
    } else {
        printl("Hit entity: " + hitEntity.GetName())
    }
    
    // Get the surface normal (direction the surface faces)
    local normal = trace.GetImpactNormal()
    
    // Get how far along the ray we hit (0.0 to 1.0)
    local fraction = trace.GetFraction()
}

Trace Types: Cheap vs Bbox

TracePlus offers two trace algorithms, each with different trade-offs:

TracePlus.Cheap: Fast, World-Only

This is a thin wrapper around vanilla TraceLine. It's fast but only detects world geometry. Use it when you're certain you only need to check for walls/floors.

// Fast trace for world geometry only
local trace = TracePlus.Cheap(startPos, endPos)

if (trace.DidHit()) {
    printl("Hit world at: " + trace.GetHitPos())
    
    // Can still get surface normal
    local normal = trace.GetImpactNormal()
}

TracePlus.Bbox: Full Detection

This is the real star. It detects both world geometry and entities by testing ray intersection with entity bounding boxes. Slightly slower, but actually useful.

// Full trace with entity detection
local trace = TracePlus.Bbox(startPos, endPos)

if (trace.DidHit()) {
    if (trace.GetEntity()) {
        printl("Hit entity: " + trace.GetEntity().GetClassname())
    } else {
        printl("Hit world geometry")
    }
}

Ignoring Entities

Often you want to ignore certain entities (like the player when tracing from their eyes).

local player = GetPlayerEx()
local eyePos = player.EyePosition()
local lookDir = player.EyeForwardVector()
local endPos = eyePos + (lookDir * 1000)

// Ignore the player themselves
local trace = TracePlus.Bbox(eyePos, endPos, player)

if (trace.DidHit() && trace.GetEntity()) {
    printl("Player is looking at: " + trace.GetEntity().GetName())
}

// Ignore multiple entities (pass an array)
local ignoreList = [player, playerWeapon, playerAttachment]
local trace2 = TracePlus.Bbox(eyePos, endPos, ignoreList)

FromEyes: Convenient Player Traces

Tracing from the player's view is so common that PCapture provides shortcuts.

local player = GetPlayerEx()

// Trace 500 units from where player is looking
local trace = TracePlus.FromEyes.Bbox(500, player)

if (trace.DidHit()) {
    printl("Player is looking at: " + trace.GetHitPos())
}

// Cheap version (world-only)
local cheapTrace = TracePlus.FromEyes.Cheap(1000, player)

Advanced Filtering: TracePlus.Settings

For complex scenarios, create custom trace settings to filter what gets detected.

Ignore by Classname

// Ignore all triggers and brush entities
local settings = TracePlus.Settings.new()
    .SetIgnoredClasses(["trigger_", "func_brush"])

local trace = TracePlus.Bbox(startPos, endPos, null, settings)
// This trace will pass through all triggers and func_brushes

Ignore by Model

// Ignore all glass props
local settings = TracePlus.Settings.new()
    .SetIgnoredModels(["models/props/glass"])

local trace = TracePlus.Bbox(startPos, endPos, null, settings)

Custom Filter Functions

For ultimate control, provide custom filter functions.

// Only hit entities with health > 50
local settings = TracePlus.Settings.new()
    .SetCollisionFilter(function(entity, note) {
        return entity.GetHealth() > 50
    })

// Ignore entities that are invisible
local settings2 = TracePlus.Settings.new()
    .SetIgnoreFilter(function(entity, note) {
        return entity.GetAlpha() == 0
    })

local trace = TracePlus.Bbox(startPos, endPos, null, settings)

Precision Settings

// For hitting very thin objects or getting precise normals
local preciseSettings = TracePlus.Settings.new()
    .SetDepthAccuracy(2)        // Lower = more precise (default 5, min 0.3)
    .SetBynaryRefinement(true)  // Extra precision for hit point

local trace = TracePlus.Bbox(startPos, endPos, null, preciseSettings)

The Legendary Feature: Portal Tracing

This is where PCapture-Lib becomes truly magical. Portal tracing lets rays pass through portals, correctly transforming position and direction.

// Trace that goes through portals
local trace = TracePlus.PortalBbox(startPos, endPos)

if (trace.DidHit()) {
    printl("Final hit position: " + trace.GetHitPos())
    
    // Check if we went through any portals
    local portalInfo = trace.GetAggregatedPortalEntryInfo()
    if (portalInfo.len() > 0) {
        printl("Trace passed through " + portalInfo.len() + " portal(s)")
        
        foreach (entry in portalInfo) {
            printl("Portal entry at: " + entry.GetHitPos())
        }
    }
}

Portal Trace Types

// Cheap portal trace (world-only, but through portals)
local cheapPortalTrace = TracePlus.PortalCheap(startPos, endPos)

// Full bbox portal trace (entities + portals)
local bboxPortalTrace = TracePlus.PortalBbox(startPos, endPos)

// From player eyes through portals
local eyeTrace = TracePlus.FromEyes.PortalBbox(1000, player)

Portal Setup Requirements

For portal tracing to work correctly:

  • For prop_portal: If created manually with non-zero pairId, set the health keyvalue to match pairId
  • For linked_portal_door: Set the model keyvalue to the portal's width and height (e.g., "128 64")
  • To disable portals for tracing, call portal.SetTraceIgnore(true)

Practical Example: Smart Laser Turret

LaserTurret <- class {
    turretEntity = null
    targetEntity = null
    laserBeam = null
    settings = null
    
    constructor(turretName) {
        this.turretEntity = entLib.FindByName(turretName)        
        this.laserBeam = entLib.FindByName(turretName + "beam")   
        // Trace settings: ignore triggers, prioritize players
        this.settings = TracePlus.Settings.new()
            .SetIgnoredClasses(["trigger_"])
            .SetPriorityClasses(["player"])        
    }
    
    function Think() {
        local turretPos = turretEntity.GetOrigin()
        local turretAngles = turretEntity.GetAngles()
        
        // Get forward direction from angles
        local forward = turretEntity.GetForwardVector()
        local endPos = turretPos + (forward * 2000)
                
        // Trace with portal support
        local trace = TracePlus.PortalBbox(turretPos, endPos, turretEntity, settings)
        
        // Update laser visual
        laserBeam.SetKeyValue("targetpoint", trace.GetHitPos())
        
        if (trace.DidHit()) {
            local hitEnt = trace.GetEntity()
            
            if (hitEnt && hitEnt.IsPlayer()) {
                if (targetEntity != hitEnt) {
                    targetEntity = hitEnt
                    printl("Target acquired!")
                    // do something
                }
            } else {
                targetEntity = null
            }
        }
        
        return 0.1  // Think every 0.1 seconds
    }
}

// Usage
local myTurret = LaserTurret("security_turret_01")
// Connect Think to a logic_timer or entity think function. Or use ActionScheduler
ScheduleEvent.AddInterval("TurretThink", myTurret.Think, 0.1, 0, null, myTurret)

Practical Example: Interaction System

InteractionSystem <- class {
    lastHighlighted = null
    
    function CheckPlayerLookAt() {
        local player = GetPlayerEx()
        
        // Settings: only hit interactable objects
        local settings = TracePlus.Settings.new()
            .SetCollisionFilter(function(ent, note) {
                // Only hit entities with "interactable" in user data
                return ent.GetUserData("interactable") != null
            })
        
        local trace = TracePlus.FromEyes.Bbox(200, player, null, settings)
        
        if (trace.DidHit()) {
            local hitEnt = trace.GetEntity()
            
            if (hitEnt) {
                if (!macros.IsEqual(hitEnt, lastHighlighted)) {
                    // Unhighlight previous
                    if (lastHighlighted) {
                        lastHighlighted.SetColor("255 255 255")
                    }
                    
                    // Highlight new
                    hitEnt.SetColor("255 255 0")
                    lastHighlighted = hitEnt
                    
                    printl("Looking at: " + hitEnt.GetName())
                }
            }
        } else {
            // Not looking at anything
            if (lastHighlighted) {
                lastHighlighted.SetColor("255 255 255")
                lastHighlighted = null
            }
        }
        
        return 0.05  // Check frequently for responsive highlighting
    }
}

// Mark entities as interactable
local button = entLib.FindByName("puzzle_button")
button.SetUserData("interactable", true)

local lever = entLib.FindByName("puzzle_lever")
lever.SetUserData("interactable", true)

Performance Considerations

TracePlus.Bbox is more expensive than TraceLine because it tests bounding box intersections for every entity near the ray path. However, it's heavily optimized with:

  • Spatial culling: Only tests entities within the trace's bounding region
  • Early termination: Stops as soon as the closest hit is found
  • Caching: Entity bounding boxes are cached and reused
  • Segmented search: Splits long traces into segments for efficiency

Performance Tip: If you're tracing frequently (e.g., in a Think function), use TracePlus.Cheap when possible and only use Bbox when you actually need entity detection. For laser beams that need constant checks, consider caching results or only updating when entities move.

Best Practices

  • Use TracePlus.Bbox when you need entity detection
  • Use TracePlus.Cheap when you only need world geometry (it's faster)
  • Always ignore the entity you're tracing from (e.g., player, turret)
  • Use FromEyes shortcuts for player view traces
  • Use Settings to filter out irrelevant entities
  • Cache trace results when possible instead of recomputing every frame
  • ❌ Don't use Bbox in performance-critical loops without caching