Tribes 2 // Mod Development Handbook GitHub

Scheduling and events

TorqueScript is single-threaded and non-blocking. There is no sleep, no wait, no loop-until. Anything that happens later happens because you scheduled it, or because the engine called you back.

schedule — deferred calls

Two forms, both used heavily in the shipped scripts [script].

Global form

schedule(<delayMS>, <objectID>, <functionName>, <arg1>, <arg2>, );
schedule(1000, 0, dedCheckLoginDone);
schedule(0, ServerGroup, loadMissionStage1, %missionName, %missionType, %firstMission);
schedule(15000, ServerGroup, loadMissionStage2);
schedule(3000, ServerGroup, CycleMissions);
schedule( 1000, 0, "checkVehicleCamping", 1 );

The second argument is the owning object. Pass 0 for no owner. If you pass an object, deleting that object cancels the schedule — which is exactly why the mission loader owns its schedules with ServerGroup: tearing down ServerGroup at mission change cancels any pending mission-load steps.

Method form

<object>.schedule(<delayMS>, <methodName>, <arg1>, …);
%client.schedule(500, "nextObjective", %client);
%game.schedule(3000, flagReturn, %obj);
%game.schedule(5000, forceRespawn, %clVictim);
StartupGui.schedule( 1000, checkLoginDone, %editAcct );
LoginEditBox.schedule( %time, makeFirstResponder, 1 );

Calls a method on that object after the delay. The object is implicitly the owner — deleting it cancels the schedule. This is the form you should reach for by default: it is self-cancelling.

The function name may be quoted or bare; both appear in the shipped scripts and behave identically.

cancel — stopping a schedule

schedule returns a handle. Store it, cancel it later.

%client.waypointSchedule = %game.schedule(%game.waypointFrequency, "showTargetWaypoint", %client);

cancel(%client.waypointSchedule);

scripts/BountyGame.cs is the model here [script] — every repeating schedule is stored on the client object and cancelled when the player dies, leaves, or the objective changes:

cancel(%client.waypointSchedule);
cancel(%client.awaitingTargetThread);
cancel(%client.forceRespawnThread);

Store every repeating schedule handle on an object. A repeating schedule you cannot cancel runs until the process exits, and each mission restart adds another one.

Repeating work

There is no timer object. You reschedule from inside the handler:

function myModTick()
{
    do the work 
   $myModTickHandle = schedule(1000, 0, myModTick);
}

The shipped code does this constantly:

function updateSubmitButton()
{
   if ( !CreateAccountDlg.open )
      return;                              // ← exit condition, no reschedule
   
   schedule( 1000, 0, updateSubmitButton );
}
function StartupGui::checkLoginDone( %this, %editAcct, %emailCheck )
{
   %result = WONLoginResult();
   %status = getField( %result, 0 );

   if ( %status $= "Waiting" )
      %this.loginSchedule = %this.schedule( 1000, checkLoginDone, %editAcct, %emailCheck );
   else
                                          // ← done, stop rescheduling
}

Two things to copy from these: the exit condition comes first, and the handle is stored on the object.

Time

Call Returns
getSimTime() Milliseconds of simulation time. Used 132 times in the shipped scripts — this is the standard clock.
getRealTime() Wall-clock milliseconds

Simulation time is what you want for gameplay timing. The classic cooldown idiom [script]:

$HandInvThrowTimeout = 0.8 * 1000; // 1/2 second between throwing grenades or mines

function HandInventory::onUse(%data, %obj)
{
   %tossTimeout = getSimTime() - %obj.lastThrowTime[%data];
   if(%tossTimeout < $HandInvThrowTimeout)
      return;
   
   %obj.lastThrowTime[%data] = getSimTime();
}

Note %obj.lastThrowTime[%data] — a per-object, per-datablock timestamp using array syntax on a dynamic field. Copy this pattern for any per-player cooldown.

The comment says half a second; the value is 800 ms. Sierra’s comment is wrong, not the code. Trust the code.

Callbacks

Most of your mod runs as callbacks, not scheduled work. The engine and the shipped scripts call named functions when things happen; you supply or override them.

Family Called when Defined in
<className>::onAdd, ::onRemove Object created / destroyed Various
<className>::onCollision(%data, %obj, %col) Physical contact item.cs, pack.cs
<className>::onUse(%data, %obj) Player uses the item weapons.cs, pack.cs
<className>::onInventory(%data, %obj, %amount) Inventory count changes weapons.cs, pack.cs, item.cs
<className>::onMount / ::onUnmount(%this, %obj, %slot) Image mounted to a slot weapons.cs
<className>::onPickup(%this, %obj, %shape, %amount) Item picked up weapons.cs
<className>::damageObject(…) Damage applied damageTypes.cs
DefaultGame::* Game lifecycle — see Gametypes defaultGame.cs
GuiControl::onWake / ::onSleep GUI shown / hidden GUI scripts
clientCmd* Server sent a command Client scripts
Message callbacks Server sent a message Registered via addMessageCallback

To hook one, override it in a package. See Packages.

Object lifetime and cleanup

Scheduling and lifetime are entangled: a schedule owned by a deleted object is cancelled, and an object in a deleted group is deleted.

$instantGroup

Newly created objects are automatically added to whatever group $instantGroup names. loadMissionStage2 sets it to MissionCleanup after the .mis file executes [script]:

$instantGroup = ServerGroup;

exec(%file);                      // the mission file
$instantGroup = MissionCleanup;   // everything after this cleans up at mission end

So objects your mod creates during a mission are cleaned up for you. Do not change $instantGroup unless you intend to; restore it if you do.

Explicit is better

%thrownItem = new Item()
{
   dataBlock = %data.thrownItem;
   sourceObject = %obj;
};
MissionCleanup.add(%thrownItem);

HandInventory::onUse adds explicitly even though $instantGroup would have handled it [script]. Follow that lead — it survives someone else changing $instantGroup.

Deletion cancels

if(%obj.thrownChargeId > 0)
{
   %obj.thrownChargeId.delete();
   %obj.thrownChargeId = 0;
}

Delete, then null the reference. Any schedules owned by the deleted object are cancelled as a side effect.

The mission teardown sequence

Worth knowing because it is when your state gets destroyed [script]loadMissionStage1:

Game.endMission();
$lastMissionTeamCount = Game.numTeams;

MissionGroup.delete();
MissionCleanup.delete();
Game.deactivatePackages();
Game.delete();
$ServerGroup.delete();
$ServerGroup = new SimGroup(ServerGroup);

Everything mission-scoped goes: the mission objects, the runtime objects, the gametype packages, the Game object itself, and the server group with all its pending schedules.

State that must survive a mission change belongs in a global ($MyMod::…) or on a GameConnection object in ClientGroup, not on Game and not in MissionCleanup.

Common mistakes

Mistake Symptom Fix
Repeating schedule with no exit condition Handler count grows every mission; eventual slowdown Check state at the top and return without rescheduling
Not storing the schedule handle Cannot cancel; duplicates accumulate Store on an object, cancel() it
Owner 0 on a mission-scoped schedule Fires after the mission ended, into deleted objects Own it with ServerGroup, MissionCleanup, or the relevant object
State on the Game object Silently lost at mission change Use a global or the client object
Assuming schedule(0, …) runs immediately It runs on the next tick That is usually what you want — it is how loadMission yields
Using getRealTime() for gameplay Diverges from simulation getSimTime()

Under the community patches

schedule, cancel, ownership, getSimTime, $instantGroup, and the mission teardown sequence are all unchanged.

Two patch-side uses are worth knowing because they demonstrate the idioms above and because you may encounter them.

The auth-phase timeout. t2csri_server schedules a 15-second expiry per connecting client and cancels it on success [patch-script]:

%client.tterm = schedule(15000, 0, t2csri_expireClient, %client);

if (isEventPending(%client.tterm))
   cancel(%client.tterm);

Note isEventPending() before cancel() — a guard the vanilla scripts mostly skip, and a good habit. Note also the handle stored on the client object, exactly as recommended above.

The zero-delay defer. RC2a’s autoexec script uses schedule(0, 0, …) to push work past the end of the autoexec glob [patch-script]:

schedule(0, 0, exec, "t2csri/serverglue.cs");

This is the standard fix when your entry script must run after files whose load order you do not control — which on RC2a includes the patch’s own. See RC2a.

One caution. On a patched server, ClientGroup can contain clients that are still authenticating. A repeating schedule that iterates clients should guard on %client.doneAuthenticating — see Client/server split.