Chapter 5.5: Perfect Timing - ActionScheduler
The Problem: In Chapter 2.5, we learned to create timed sequences with EntFire. But there's a critical limitation: you cannot cancel scheduled EntFire actions. Once you fire a delayed input, it's locked in—even if the situation changes.
// Vanilla VScript: The point of no return
function StartAlarmSequence() {
EntFire("alarm_light", "TurnOn", "", 0)
EntFire("alarm_light", "TurnOff", "", 5)
EntFire("alarm_sound", "PlaySound", "", 0)
EntFire("alarm_sound", "StopSound", "", 5)
}
// If the player solves the puzzle at 2 seconds, the alarm STILL runs for 3 more seconds
// There's no way to cancel it!
Additionally, you must manually calculate cumulative delays, making the code hard to read and modify.
The Solution: Cancelable, Clean Scheduling
PCapture-Lib's ActionScheduler module gives you full control over timed events. You can schedule actions, organize them into named event groups, and—most importantly—cancel them at any time.
Basic Scheduling
// Schedule a single action after 2 seconds
function SayMessage() {
printl("This message appears after 2 seconds.")
}
ScheduleEvent.Add("MyMessage", SayMessage, 2.0)
// The action is tied to the event name "MyMessage"
// We can cancel it before it executes:
ScheduleEvent.Cancel("MyMessage") // Message will never print
Named Events: The Killer Feature
Group related actions under a single event name. You can then cancel the entire sequence with one command.
function StartAlarmSequence() {
// All actions belong to the "alarm_sequence" event
ScheduleEvent.Add("alarm_sequence", function() {
EntFire("alarm_light", "TurnOn")
EntFire("alarm_sound", "PlaySound")
}, 0)
ScheduleEvent.Add("alarm_sequence", function() {
EntFire("alarm_light", "TurnOff")
EntFire("alarm_sound", "StopSound")
}, 5)
printl("Alarm sequence started. Will run for 5 seconds.")
}
function OnPuzzleSolved() {
// Player solved the puzzle early - cancel the entire alarm!
ScheduleEvent.Cancel("alarm_sequence")
// Immediately turn everything off
EntFire("alarm_light", "TurnOff")
EntFire("alarm_sound", "StopSound")
printl("Alarm sequence cancelled!")
}
This is impossible with vanilla EntFire. Once those delayed inputs are scheduled, they will execute no matter what. With ScheduleEvent, you have full control.
The Scope Problem and Three Solutions
When you schedule a function to run later, it doesn't automatically "remember" local variables from where you called it. You must explicitly pass them.
// ❌ BROKEN: This will crash!
function StartSequence() {
local door = entLib.FindByName("exit_door")
ScheduleEvent.Add("test", function() {
door.SetColor("255 0 0") // ERROR: 'door' doesn't exist here!
}, 1)
}
ActionScheduler gives you three ways to solve this:
Solution 1: Pass as Arguments (Recommended)
function StartSequence() {
local door = entLib.FindByName("exit_door")
// Pass 'door' in the args array (4th parameter)
ScheduleEvent.Add("test", function(door) {
door.SetColor("255 0 0")
}, 1, [door]) // ← Pass variables here
}
Solution 2: Capture with :(variable)
function StartSequence() {
local door = entLib.FindByName("exit_door")
// Capture 'door' with :(door) syntax
ScheduleEvent.Add("test", function():(door) {
door.SetColor("255 0 0")
}, 1)
}
Solution 3: Use scope Parameter (For Classes)
MyController <- class {
door = null
function Init() {
door = entLib.FindByName("exit_door")
}
function StartSequence() {
// Pass 'this' as scope (5th parameter)
ScheduleEvent.Add("test", function() {
this.door.SetColor("255 0 0") // 'this' = MyController
}, 1, null, this) // ← Pass 'this' here
}
}
But you can also do it this way:
function StartSequence() {
local door = entLib.FindByName("exit_door")
// Pass 'door' as scope (5th parameter)
ScheduleEvent.Add("test", function() {
this.SetColor("255 0 0")
}, 1, null, door) // ← Pass door scope here
}
Use Solution 1 (args) by default. It's the clearest. Use Solution 3 (scope) when working inside classes.
Repeating Actions with Intervals
Need something to happen repeatedly? Use AddInterval.
// Flash a light every 0.5 seconds
local light = entLib.FindByName("warning_light")
ScheduleEvent.AddInterval("alarm_flash", function(light) {
if (light.GetAlpha() > 0) {
light.SetAlpha(0)
} else {
light.SetAlpha(255)
}
}, 0.5, 0, [light]) // interval, initialDelay, args
// Stop after 10 seconds
ScheduleEvent.Add("global", function() {
ScheduleEvent.Cancel("alarm_flash")
}, 10)
The Power of yield: Sequential Code with Pauses
The most powerful feature of the scheduler is the ability to write asynchronous code. The yield keyword lets you pause a function's execution for a set time, then resume exactly where you left off. This allows you to write timed sequences in a natural, linear way.
// A complex sequence with pauses, written linearly
function RunLightSequence() {
printl("Sequence started...")
EntFire("light_1", "TurnOn")
yield 1.0 // Wait 1 second
EntFire("light_2", "TurnOn")
yield 1.0 // Wait another second
EntFire("light_3", "TurnOn")
yield 2.5 // Wait longer
EntFire("light_*", "TurnOff")
printl("Sequence complete.")
}
// Must use ScheduleEvent to enable 'yield'
ScheduleEvent.Add("LightSequence", RunLightSequence, 0)
// Can cancel the entire sequence mid-execution!
ScheduleEvent.Cancel("LightSequence")
Yield with Variables
function ComplexSequence() {
local door = entLib.FindByName("sequence_door")
local light = entLib.FindByName("sequence_light")
// Turn light red
light.SetColor("255 0 0")
EntFire(light.GetName(), "TurnOn")
yield 1.0
// Turn light green
light.SetColor("0 255 0")
yield 2.0
// Open door
EntFire(door.GetName(), "Open")
yield 1.0
printl("Sequence complete!")
}
// Capture variables with :() syntax
ScheduleEvent.Add("complex", function():(door, light) {
ComplexSequence()
}, 0)
Asynchronous Loops
function MonitorPlayer() {
local player = GetPlayerEx()
while (true) { // Infinite loop - safe with yield!
local health = player.GetHealth()
printl("Player health: " + health)
if (health < 30) {
printl("WARNING: Low health!")
}
yield 0.5 // Check every 0.5 seconds
}
}
ScheduleEvent.Add("health_monitor", MonitorPlayer, 0)
// Stop monitoring
ScheduleEvent.Cancel("health_monitor")
yieldThe Source Engine's save/load system does not support asynchronous VScript functions. If a player saves the game while a function is "paused" on a
yield statement, loading that save will crash the game.Therefore, you should only use
yield for:
- Short, non-critical animations or sequences where a save is unlikely
- Debugging purposes
- Intro cutscenes before player gains control
- Essential but extremely short pauses (e.g.,
yield 0.01for file system operations)
ScheduleEvent.Add() calls or AddInterval.
Event Management Functions
Checking and Canceling
// Check if an event exists and has scheduled actions
if (ScheduleEvent.IsValid("my_sequence")) {
printl("Sequence is still running")
}
// Cancel an event (throws error if event doesn't exist)
ScheduleEvent.Cancel("my_sequence")
// Safely try to cancel (no error if event doesn't exist)
ScheduleEvent.TryCancel("my_sequence")
// Cancel all events except "global"
ScheduleEvent.CancelAll()
The "global" Event
There's a special event called "global" that always exists and is never automatically deleted. Use it for one-off actions that don't need grouping.
// Quick one-off delay
ScheduleEvent.Add("global", function() { // You CAN't cancel "global" manually
printl("This happens after 2 seconds")
}, 2)
Comparison: EntFire vs ScheduleEvent
| Feature | EntFire | ScheduleEvent |
|---|---|---|
| Cancelable? | ❌ No | ✅ Yes |
| Group actions? | ❌ No | ✅ Named events |
Supports yield? |
❌ No | ✅ Yes |
| Can run any code? | ❌ Only entity inputs | ✅ Any function |
| Requires PCapture-Lib? | ✅ No | ❌ Yes |
Performance Notes
The ActionScheduler uses a logic_timer entity that ticks every `FrameTime` seconds, but this overhead is negligible. The scheduler only processes actions whose execution time has arrived, and scheduled actions are stored in a sorted list for efficient lookups. Even with hundreds of queued actions, performance impact is minimal.