Scheduler
Run code on the next frame, after a delay or on a repeat — on the main thread, and cleaned up with your plugin.
ISource2ModScheduler is how a plugin defers work: once on the next game frame,
once after a delay, or repeatedly. Every callback runs on the main thread, and
everything you schedule belongs to your plugin and is dropped when it unloads.
#include "source2mod/ISource2ModScheduler.h"#include <source2mod/api>| Call | Macro |
|---|---|
NextFrame(TimerCallback task) | NEXT_FRAME(task) |
AddTimer(float interval, TimerCallback callback, int flags = 0) | ADD_TIMER(interval, cb, ...) |
KillTimer(Timer* timer) | KILL_TIMER(timer) |
TimerCallback is Source2ModCallback<void()>: a lambda, a free or static
function, or an object and a method (SOURCE2MOD_MEMBER(this, &Plugin::Tick)).
There is no plugin id argument; the core reads which plugin a task or timer
belongs to off the callback itself. The macros just forward.
A script calls Source2ModScheduler.NextFrame(task, data),
Source2ModScheduler.AddTimer(interval, callback, data, flags) and
Source2ModScheduler.KillTimer(timer). The callback is a named function
void (any data), and data is the one value it is handed — an entity, a
guid, or a DataPack for more.
Next frame
The classic use is an entity the game is still filling in when you hear about
it. From the sample plugin — a change made in OnEntitySpawned() would be
overwritten, one frame later it sticks:
void SamplePlugin::OnEntitySpawned(CEntityInstance* pEntity)
{
if (V_strcmp(pEntity->GetClassname(), "smokegrenade_projectile") != 0)
return;
// A raw pointer must not cross a frame -- the entity may be gone by then.
// A handle resolves to nullptr instead.
CHandle<CSmokeGrenadeProjectile> hSmoke = static_cast<CSmokeGrenadeProjectile*>(pEntity)->GetHandle();
NEXT_FRAME([hSmoke]()
{
if (CSmokeGrenadeProjectile* pSmoke = hSmoke.Get())
pSmoke->m_vSmokeColor = Vector(170.0f, 0.0f, 255.0f);
});
}public void OnPluginStart()
{
Source2ModEntities.AddEntityListener(INVALID_FUNCTION, OnEntitySpawned);
}
public void OnEntitySpawned(CEntityInstance entity)
{
char classname[64];
entity.GetClassname(classname, sizeof(classname));
if (!Source2ModUtils.Equal(classname, "smokegrenade_projectile"))
return;
// The entity is a handle, not a pointer: should the smoke be gone by the
// next frame (and its index reused), IsValid() says so.
Source2ModScheduler.NextFrame(PaintSmoke, entity);
}
public void PaintSmoke(any data)
{
CSmokeGrenadeProjectile smoke = view_as<CSmokeGrenadeProjectile>(data);
if (!smoke.IsValid())
return;
float purple[3] = { 170.0, 0.0, 255.0 };
smoke.Set_m_vSmokeColor(purple);
}NextFrame() is the one scheduler call that is safe from any thread (the queue
is locked), which makes it the way to hand a result from your own worker thread
back to the main thread.
Timers
AddTimer() returns a Timer* (a Source2ModTimer in a script). Without flags the timer fires once after
interval seconds.
// One-shot: stop a sound 0.3 s after it started.
ADD_TIMER(0.3f, [guid]() { SOUND_STOP(guid); });// One-shot: stop a sound 0.3 s after it started.
Source2ModScheduler.AddTimer(0.3, Timer_StopSound, guid);
public void Timer_StopSound(any guid)
{
Source2ModSounds.StopSound(guid);
}Flags
| Flag | Meaning |
|---|---|
TIMER_FLAG_REPEAT | Fires every interval seconds until killed |
TIMER_FLAG_NO_MAPCHANGE | Killed automatically on map change |
A repeating countdown, adapted from the sample plugin:
Timer* m_pCountdownTimer = nullptr;
int m_nCountdown = 0;
void StartCountdown(int seconds)
{
if (m_pCountdownTimer)
{
KILL_TIMER(m_pCountdownTimer);
m_pCountdownTimer = nullptr;
}
m_nCountdown = seconds;
m_pCountdownTimer = ADD_TIMER(1.0f, [this]()
{
// ... print m_nCountdown to everyone ...
// A timer may kill itself from inside its own callback.
if (m_nCountdown-- <= 0)
{
KILL_TIMER(m_pCountdownTimer);
m_pCountdownTimer = nullptr;
}
}, TIMER_FLAG_REPEAT | TIMER_FLAG_NO_MAPCHANGE);
}
void SamplePlugin::OnLevelShutdown()
{
// TIMER_FLAG_NO_MAPCHANGE: the scheduler has killed it, and the pointer with it.
m_pCountdownTimer = nullptr;
}Source2ModTimer g_CountdownTimer;
int g_Countdown;
void StartCountdown(int seconds)
{
if (g_CountdownTimer)
{
Source2ModScheduler.KillTimer(g_CountdownTimer);
g_CountdownTimer = view_as<Source2ModTimer>(0);
}
g_Countdown = seconds;
g_CountdownTimer = Source2ModScheduler.AddTimer(1.0, Timer_Countdown, 0, TIMER_FLAG_REPEAT | TIMER_FLAG_NO_MAPCHANGE);
}
public void Timer_Countdown(any data)
{
// ... print g_Countdown to everyone ...
// A timer may kill itself from inside its own callback.
if (g_Countdown-- <= 0)
{
Source2ModScheduler.KillTimer(g_CountdownTimer);
g_CountdownTimer = view_as<Source2ModTimer>(0);
}
}
public void Source2Mod_OnLevelShutdown()
{
// TIMER_FLAG_NO_MAPCHANGE: the scheduler has killed it, and the handle with it.
g_CountdownTimer = view_as<Source2ModTimer>(0);
}Long-lived repeating timers are typically started once in Load():
ADD_TIMER(1.0f, Timers::HeartBeat, TIMER_FLAG_REPEAT);Source2ModScheduler.AddTimer(1.0, Timer_HeartBeat, 0, TIMER_FLAG_REPEAT);Debouncing
Timers are cheap, so "save a few seconds after the last change" is just a
timer plus a sequence number. FUNPLAY's volume menu does exactly this; a
script carries the SteamID and the number in a DataPack:
const int seq = ++data->SaveSeq;
ADD_TIMER(3.0f, [steamid, seq]()
{
PlayerData* d = FindPlayer(steamid);
if (d && d->SaveSeq == seq) // only the last change saves
Save(steamid, d);
});int seq;
g_SaveSeq.GetValue(steamId, seq);
g_SaveSeq.SetValue(steamId, ++seq);
DataPack pack = new DataPack();
pack.WriteString(steamId);
pack.WriteCell(seq);
Source2ModScheduler.AddTimer(3.0, Timer_Save, pack);
public void Timer_Save(any data)
{
DataPack pack = view_as<DataPack>(data);
pack.Reset();
char steamId[32];
pack.ReadString(steamId, sizeof(steamId));
int seq = pack.ReadCell();
delete pack;
int last;
if (g_SaveSeq.GetValue(steamId, last) && last == seq) // only the last change saves
Save(steamId);
}Pitfalls
- A
Timer*dies with its timer. A one-shot is deleted right after it fires; a repeating one when it is killed — by you, by a map change withTIMER_FLAG_NO_MAPCHANGE, or by your plugin unloading. CallingKILL_TIMERon a pointer that is already gone is a use-after-free. Clear your copy when the timer ends, and do not keep pointers to one-shot timers you might kill after they fired. (A script'sSource2ModTimeris checked: killing one that has ended is harmless, but clear it all the same.) - Resolution is about 0.1 s. Timers are checked every 100 ms of game time,
so a 0.05 s interval fires about as often as a 0.1 s one. For per-frame work
use
NEXT_FRAME(re-queued from itself) or a game frame hook instead. - Capture handles, not raw entity pointers. Anything that crosses a frame
must survive the entity being deleted — capture a
CHandle, a slot or a SteamID and re-resolve it inside the callback. A script's entities are handles already; checkIsValid()in the callback. - Exceptions are swallowed. A callback that throws is caught and ignored, so the timer keeps going (or is dropped, for a one-shot) without a trace.
- Main thread.
AddTimer()andKillTimer()are not thread-safe; onlyNextFrame()is. - Unload. Timers and pending next-frame tasks belong to the plugin whose
callback they hold, and are dropped before its library is closed — a
std::functiondestroyed afterwards would call into unmapped memory.KILL_TIMERinUnload()is not needed; it is for turning a feature off while the plugin stays loaded.
Reference
Menus
Menus with numbered options, paging, per-player instances and cleanup on unload — in the center panel (CenterHtmlMenu) or on the Panorama HUD (HudMenu).
Sounds
Play sound events for exactly the players you pick, stop or change them by guid, give players volume channels, and rewrite the game's own sounds.