Tribes 2 // Mod Development Handbook GitHub

40 · The ruleset toggles

Classic 1.5 shipped a mechanism worth more than most of the gameplay changes around it: optional rules. Nine weapon and vehicle behaviours, plus a gametype, each switchable by a server pref, each defaulting to off, each announced to the client on connect.

It is the cleanest answer in the Tribes 2 corpus to a problem every mod eventually has — some of my users want this change and some do not — and it is directly stealable.

The switches

All ten live in scripts/serverDefaults.cs and all ten default to 0 [mod-script]:

$Host::ClassicLoadTR2Gametype = 0;                    // Option to not load Tr2 gametype
$Host::ClassicLoadPlasmaTurretChanges = 0;            // Plasma turret does less damage and projectile si slower.
$Host::ClassicLoadHavocChanges = 0;                   // Havoc gets a built in sensor jammer with 20 meter radius.
$Host::ClassicLoadSniperChanges = 0;                  // Sniper Rifle uses ammo with 12 shots and energy.
$Host::ClassicLoadMissileChanges = 0;                 // Handheld missile launcher will not lock onto players and can no-lock fire.
$Host::ClassicLoadMortarChanges = 0;                  // Handheld mortar range limited to 450 meters.
$Host::ClassicLoadBlasterChanges = 0;                 // Blaster shoots 6 projectiles ala shotgun.
$Host::ClassicLoadPlayerChanges = 0;                  // Load up new gameplay changes allowing players to be shot while in vehicles.
$Host::ClassicLoadMineChanges = 0;                    // Enable/Disable mine disc.
$Host::ClassicLoadVRamChanges = 0;                    // Vehicles take damage when ramming players.

(The typo in the plasma comment is theirs. Quoted as shipped.)

Toggle On Off (default)
…TR2Gametype Team Rabbit 2 gametype loads Not loaded
…PlasmaTurretChanges Less damage, slower projectile Stock plasma turret
…HavocChanges Havoc carries a 20 m sensor jammer Stock Havoc
…SniperChanges Sniper uses ammo (12) and energy Energy only
…MissileChanges No player lock; dumbfire allowed Locks onto players
…MortarChanges Range capped at 450 m Uncapped
…BlasterChanges Six projectiles, shotgun-style Single projectile
…PlayerChanges Players in vehicles can be blastered, sniped, chained Protected in vehicles
…MineChanges Mine discing disabled Mine discing allowed
…VRamChanges Vehicles damage players they ram No ram damage

Two are worth reading carefully because their polarity is counterintuitive. MineChanges on removes a capability, and PlayerChanges on removes protection. The names describe “load the changes”, not “make the game more permissive” — a naming trap the mod never resolved, and one to avoid in your own prefs.

Two ways they are applied

The implementation is not uniform, and the difference is the actual lesson.

Datablock-time — the ternary in the body

weapons/blaster.cs [mod-script]:

{
   emitterDelay        = -1;
   // z0dd - ZOD, 5/07/04. Less damage shotgun blaster is gameplay changes in affect
   directDamage        = $Host::ClassicLoadBlasterChanges ? 0.05 : 0.15;
   directDamageType    = $DamageType::Blaster;
   ...
   // z0dd - ZOD, 5/07/04. Shotgun blaster no sound when gameplay changes in affect
   sound = $Host::ClassicLoadBlasterChanges ? "" : BlasterProjectileSound;

and on the image datablock:

   usesEnergy = true;
   // z0dd - ZOD, 5/07/04. More drain shotgun blaster is gameplay changes in affect
   fireEnergy = $Host::ClassicLoadBlasterChanges ? 8 : 4;
   minEnergy  = $Host::ClassicLoadBlasterChanges ? 8 : 4;

The expression is evaluated once, when the datablock is constructed — at mission load. From then on the field holds a literal number. Changing the global afterwards does nothing until datablocks are rebuilt.

That is not a defect; it is the right choice here. Datablock fields are ghosted to clients, so the value has to be fixed before it is transmitted. It does mean the pref must be set before the first mission loads, which is why these live in serverPrefs.cs rather than being votable.

Call-time — the branch in the method

The same file, same toggle, different mechanism:

function BlasterImage::onFire(%data, %obj, %slot)
{
   if(!$Host::ClassicLoadBlasterChanges)
   {
      Parent::onFire(%data, %obj, %slot);
      return;
   }
    six-projectile spread 
}

Here the global is read on every shot. The guard-and-delegate shape is idiomatic: if the option is off, hand straight back to the parent implementation and return. Nothing about the stock behaviour is duplicated, so the stock path cannot drift.

Choosing between them

  Datablock ternary Method branch
Evaluated Once, at datablock construction Every call
Visible to clients Yes — ghosted with the datablock No — server-side only
Changeable at runtime No Yes
Cost None after load A global read per invocation
Use for Numbers the client must agree on: damage, energy, sounds Behaviour: firing patterns, scoring, permissions

Use both, for the same toggle, as Classic does. The numbers go in the datablock; the behaviour goes in the method. Trying to force behaviour into datablock fields produces unreadable datablocks, and trying to force damage numbers into methods desynchronises the client.

Tell the client what ruleset it joined

This is the part most mods skip, and it is the part players actually notice. On connect, the server messages each client the state of every toggle [mod-script]:

messageClient( %client, 'MsgClassic', 'Classic \c2Sniper Mod: \c3%1.', ($Host::ClassicLoadSniperChanges ? 'Enabled' : 'Disabled') );
messageClient( %client, 'MsgClassic', 'Classic \c2Missile Mod: \c3%1.', ($Host::ClassicLoadMissileChanges ? 'Enabled' : 'Disabled') );
messageClient( %client, 'MsgClassic', 'Classic \c2Mortar Mod: \c3%1.', ($Host::ClassicLoadMortarChanges ? 'Enabled' : 'Disabled') );
messageClient( %client, 'MsgClassic', 'Classic \c2Blaster Mod: \c3%1.', ($Host::ClassicLoadBlasterChanges ? 'Enabled' : 'Disabled') );
 and five more 

Nine near-identical lines. Repetitive, and correct: a player joining a server whose blaster is a shotgun needs to be told, because nothing in the interface reveals it. A server-side mod cannot change the client UI, so the chat log is the only channel available — see Client/server split and Text and messaging.

1.5.1’s changelog records the failure mode: “Added missing messages that told client what gameplay changes were in affect on the server. 1.5 was missing Mine and Havoc messages” [mod-script]. Two toggles shipped silently for six days. If your announcement list is maintained by hand, it will drift from your toggle list. Derive it — walk the names, or keep a table both sides read.

Stealing this

The pattern generalises to any mod that wants optional rules:

// 1. Declare in your defaults file, off by default.
$Host::MyModLoadFooChanges = 0;

// 2. Numbers the client must agree on — resolve at datablock time.
datablock ProjectileData(FooProjectile)
{
   directDamage = $Host::MyModLoadFooChanges ? 0.05 : 0.15;
};

// 3. Behaviour — branch at call time, delegating when off.
function FooImage::onFire(%data, %obj, %slot)
{
   if(!$Host::MyModLoadFooChanges)
   {
      Parent::onFire(%data, %obj, %slot);
      return;
   }
   // changed behaviour only
}

// 4. Announce on connect, from a list you maintain in one place.
$MyMod::ToggleNames = "Foo" TAB "Bar" TAB "Baz";
for(%i = 0; %i < getFieldCount($MyMod::ToggleNames); %i++)
{
   %name = getField($MyMod::ToggleNames, %i);
   messageClient(%client, 'MsgMyMod', '\c2%1 Mod: \c3%2.', %name,
                 ($Host::MyModLoad[%name] ? 'Enabled' : 'Disabled'));
}

Step 4 is the improvement on Classic, and it needs the right V12 idiom.

There is no getVariable() in V12. The string does not appear anywhere in Tribes2.exe [binary], nor in any shipped script — it is a later-Torque addition, and reaching for it is a common way to write code that looks plausible and does not run.

What V12 does give you is global arrays with a runtime subscript. $Foo[%bar] resolves %bar at execution time, and the shipped scripts lean on it constantly — $TeamName[%team], $flagStatus[%flag.team], $teamScore[%i], $InventoryHudData[%i, itemDataName] [script]. So declare the toggles as an array keyed by name rather than as separately-named globals:

$Host::MyModLoad["Foo"] = 0;
$Host::MyModLoad["Bar"] = 0;

and one list then drives the announcement, any vote menu, and the datablock ternaries alike. Adding a toggle becomes a one-line change in one place, and the 1.5.1 failure — two toggles shipping with no announcement — becomes structurally impossible.

The trade-off is that these no longer read as $Host::ClassicLoadBlasterChanges in a prefs file, which matters if admins edit prefs by hand. If you need the flat names for that, keep them and drive the announcement from a parallel array of labels instead; you still get one list, just not one variable.

See TorqueScript for the array-global semantics, and Console functions for what actually exists in V12.

Where it does not reach

Toggles cost you a combinatorial test surface. Ten independent booleans is 1024 configurations, and nobody tested 1024 configurations. In practice servers ran a handful of known-good combinations, and the 1.5.x patch sequence is partly the cost of that.

Keep the count small, keep the defaults meaning “stock”, and document the combinations you actually run — which is exactly what TacoServer does when it ships a curated serverPrefs.cs (section 49) rather than exposing everything.