Guide

Megalo scripts

Write a game mode in the script language of ReachVariantTool, the community's Megalo editor, and Project Reclaimer turns it into a mode you can host. Megalo scripts reach further than version 2's JSON: they create weapons and characters, attach objects to players, set velocity, hide objects or make them invincible, and put players in other bodies, such as a Grunt's.

New in 0.8.14This is partial support: it runs the parts of the language this page lists, on Halo 3. It doesn't load Reach or Halo 4 game variants.

4example scripts to compile, play and change
8characters a player can become, from Grunt to Hunter
256objects a script can have created at once
16trait sets a script can hand out

What a Megalo script is

Megalo is the scripting behind Halo: Reach's and Halo 4's game variants, and ReachVariantTool lets the community write it as text. Project Reclaimer reads a large part of that text language, saved as a .rvt file, and runs it on Halo 3:

  • You compile it into a game mode. mode-compile checks the script and writes a Slayer game type file with the script inside, a version 2 mode like any other. That file is what you host and share; the .rvt is only for editing.
  • Only the host runs it. Players don't download or run the script. What it does reaches them through the game: scores, teams, traits, HUD rows, sizes, hidden and invincible objects, and new characters. The host and every player need 0.8.14 or later.
  • It's checked before it runs. The compiler refuses anything Project Reclaimer can't do on Halo 3, even in a part of the script that never runs, and names it, instead of leaving it out silently.
  • It's a subset. The language features and calls on this page work. Compiled Reach or Halo 4 variants (.bin, .mglo), MegaloEdit's syntax and Reach-only features such as sounds and waypoints don't; see not supported.

Compile and host

  1. Get a scriptDownload megalo-timed-score.rvt, one of the examples, into the game client's folder. Write your own in any text editor and save it as UTF-8.
  2. Open a terminal there In File Explorer, right-click inside the client's folder and choose Open in Terminal. Type .\project and press Tab for the program's name.
  3. Compile it into your Game Modes folder
    PowerShell
    .\project-reclaimer-v0.8.14.exe mode-compile .\megalo-timed-score.rvt --output "C:\Users\you\Documents\My Games\Project Reclaimer\Game Modes\timed-score.json" --name "Timed Score"
    It answers Compiled mode: and the file's path, or says what's wrong with the line and column. The compiler never overwrites a file: give it a new name, or move the old one away first.
  4. Host itIn Host Game, choose Game Type, then Refresh, select the Slayer card and choose Timed Score. Pick a map and start the game.

After changing the script, compile it again to a new file, choose Refresh and select the mode again. A round that's running keeps the script it started with. Game Options → Save as variant keeps the script and your option choices in a new file.

CommandWhat it does
megalo-check script.rvtReads the script and checks its types, then lists what it needs, such as timer.set_rate or write.player.score. It doesn't check that Halo 3 can do all of it: mode-compile does.
mode-compile script.rvt --output new.json --name "Name"Checks everything the script needs against what Project Reclaimer can do on Halo 3 and writes a playable Slayer game type with the script inside.
mode-compile … --template existing.jsonThe same, but keeping the template's game settings, scoring, options, trait sets and zones. The template's own JSON events are replaced, not combined.
mode-check mode.jsonChecks a whole game type file, compiled script included: Valid custom mode: Timed Score (script version 2).
  • Scoring: without a template, kills score nothing and there's no score limit, so the script decides the points and ends the round itself, as Timed Score does after 60 seconds. With a template, its scoring applies too: don't give points for kills twice by accident.
  • Test on the map you'll play. The compiler can't know whether a map has a weapon, where its kill boundaries are, or whether every value the script computes is in range.
  • On a dedicated server, put the compiled .json in content/Game Modes and name it like any game type; see share and host. To try one on your own PC: .\project-reclaimer-v0.8.14.exe local-server --variant .\timed-score.json, then join it from the Server Browser.

The examples

Timed Score

Every living player gets a point a second, a HUD row counts the seconds, and the round ends after 60. The smallest complete script: timers, a player loop, scoring and the HUD. megalo-timed-score.rvt

Grunts

Every player becomes a Grunt each time they spawn. Change grunt to hunter, brute or another character for a different mode. megalo-player-bipeds.rvt

Object Controls

Each life gets a half-size, invincible Shotgun stuck to the player for five seconds; then it comes loose and drifts away. A HUD row counts them, and the round ends after a minute. megalo-object-controls.rvt

Zone Traits

A hill that speeds up and lights up whoever stands in it, with points on a timer and two options hosts can change. Compiled with its template, megalo-zone-template.json. megalo-zone-traits.rvt

megalo-timed-score.rvt
-- ReachVariantTool dialect; each living player earns one point per second.
-- Compile with: project-reclaimer mode-compile megalo-timed-score.rvt
--               --output timed-score.json --name "Timed Score"
alias interval = global.timer[0]
alias elapsed = global.number[0]
declare interval = 1
declare elapsed with network priority high = 0

on init: do
   interval.set_rate(-100%)
end

if interval.is_zero() then
   for each player do
      if current_player.is_not_respawning() then
         current_player.score += 1
      end
   end
   elapsed += 1
   interval.reset()
   interval.set_rate(-100%)
end

script_widget[0].set_text("Elapsed: %n", elapsed)
script_widget[0].set_visibility(all_players, true)

if elapsed >= 60 then
   game.end_round()
end

alias gives timer 0 and number 0 of the round names, declare starts the timer at one second, and on init starts it counting down when the round starts. Everything outside on init runs on every tick: when the timer reaches zero, every living player scores, the count goes up and the timer starts again. Compile the others the same way; Zone Traits needs its template:

PowerShell
.\project-reclaimer-v0.8.14.exe mode-compile .\megalo-zone-traits.rvt --template .\megalo-zone-template.json --output .\zone-traits.json --name "Zone Traits"

Zone Traits' hill sits at the map's zero point, [0, 0, 0], which may not be anywhere players can reach. Before compiling, set the hill's position in the template to a place on your map: in Forge, fly the monitor there and read X, Y and Z on the Forge menu's Map tab. For a hill that finds its own place, play the bundled Zone Control.

The language

Variables

Scripts keep whole numbers, references to objects, players and teams, and timers, in numbered slots that belong to the round (global), to a player, a team or an object, or to the current tick only (temporaries):

Belongs toNumbersObjectsPlayersTeamsTimers
global1216888
object84424
player84444
team86444
temporaries108360
  • Slots count from 0 and must be plain numbers: global.number[11] is the last global number. A player's slots belong to that player: current_player.number[0].
  • declare is only needed for a starting value: declare player.number[0] = 3. Numbers and timers otherwise start at 0, references at no_object, no_player or no_team. with network priority low or high is accepted and ignored; local isn't accepted.
  • alias points = player.number[0] gives a slot a name. alias scratch = allocate temporary number or allocate global.number takes a free one for you; don't also write to that slot by number.
  • References stay on what they point at: when a player leaves or an object is deleted, a saved reference becomes no_player or no_object, never someone or something else. A player's variables stay through respawns, and every round starts fresh.
  • Inside loops, current_player, current_object and current_team are the one being visited; team[0] to team[7] and neutral_team are fixed teams.

Numbers, enums and functions

Numbers are whole, from about −2.1 billion to 2.1 billion, and wrap around past that. Write them as decimal, 0x hexadecimal or 0b binary. Arithmetic is done with assignments: =, +=, -=, *=, /= (rounds toward zero), %=, and the bit operations &=, |=, ^=, ~= (clear bits), <<= and >>=. References only take =. rand(4) is a whole number from 0 to 3, following the mode's random seed; abs(-7) is 7. Dividing by zero stops the script.

Enums and functions
enum Points
   ordinary = 1
   bonus
end

function award_bonus()
   current_player.score += Points.bonus
end

Enum entries count up from the last value, starting at 0. Functions take no arguments and return nothing; they work on the caller's current player, team and object. Declare a function before using it. A function may call itself, within the step limits.

Conditions and loops

if … then … end tests comparisons (== != < <= > >=) and calls such as is_zero(), joined with not, and and or. As in ReachVariantTool, or binds first: A or B and C means (A or B) and C; nest ifs for anything else. altif and alt check their conditions again rather than remembering which branch ran, so a branch that changes the value being tested can let a later one run too.

LoopGoes through
for each player doEvery player in the game, dead ones too. Check current_player.is_not_respawning() for the living.
for each player randomly doThe same players in a shuffled order.
for each team doThe 8 teams and the neutral team, empty ones too.
for each object doEvery object in the world: players' bodies, the map's objects, the script's own and its zones.
for each object with label "gift" doThe objects with that script label.

Comments start with --. do … end makes a block of its own.

Events and timers

  • on init: do … end runs once at the start of each round.
  • Everything else at the top of the script runs on every tick of the game, after that tick's deaths and spawns. A paused game has no ticks. Other events, such as pregame, object death or local, aren't available.
  • Timers count seconds, fractions included. set_rate(-100%) counts down one second per second of play, 100% counts up, 0% stops. Other rates: 10, 25, 50, 75, 125, 150, 175, 200, 300, 400, 500 and 1000%, either sign, written as a plain value.
  • A timer counting down stops at 0. reset() puts back its declared value but keeps its rate, so call set_rate again for a timer that repeats, as Timed Score does. is_zero() is a test, not an event: check it every tick.

Players, teams and the round

On a player or teamWhat it does
current_player.bipedThe player's body, or no_object while they're dead.
current_player.is_not_respawning()True while the player is alive.
current_player.scoreTheir score this round, to read or change, by up to 100 points at a time. The game type's score limit still ends the round.
current_player.teamTheir team, to read or change. Changing it needs a team game and a team the map allows; neutral_team isn't one.
current_player.apply_traits(script_traits[0])Gives them a trait set for this tick; see traits.
current_player.set_biped(body)Puts them in another body; see players in other bodies.
team[0].scoreThe team's score: its players' scores added up. Read only.
team[0].has_any_players()True when someone's on the team.
team[0].has_alliance_status(team[1], enemies)Tests two teams: a team is allied with itself, two different teams are enemies, and the neutral team is neutral. It doesn't change alliances.
game.end_round()Ends the round. Points the script gave first still count; the round has no chosen winner, so the score decides.

Players in other bodies

A script can create a character and hand a player's control over to it. The Grunts example does it each time a player spawns:

megalo-player-bipeds.rvt
-- Turn each new life into a Grunt. Use marine, hunter, jackal, brute,
-- elite_warrior, spartan or elite in place_at_me to choose another body.
-- The mode automatically includes campaign character assets in stock maps.
for each player do
    if current_player.is_not_respawning() then
        if current_player.object[0] != current_player.biped then
            global.object[0] = current_player.biped
            global.object[1] = current_player.biped.place_at_me(grunt, "player_body", none, 0, 0, 0, none)
            current_player.set_biped(global.object[1])
            global.object[0].delete()
            current_player.object[0] = current_player.biped
        end
    end
end
  • The characters: grunt, marine, jackal, brute, hunter, elite_warrior (the campaign's Elite), and the multiplayer spartan and elite.
  • Files: the campaign characters' files come with the map automatically on every map that ships with the game, for the host and for players who join, mid-game too. Halo 3's campaign has to be installed. Mod maps aren't changed this way.
  • What changes: the player plays as that character, with its animations, weapons handling, camera and vehicle seats as Halo 3 made them. They start unarmed: weapons aren't carried over, but weapon and ammo effects the script gives follow the new body. There are no new first-person arms.
  • What stays: their variables, team and score. current_player.biped is the new body at once.
  • The old body stays where it was until the script deletes it, as the example does, or the game cleans it up.
  • Death: players respawn as usual, in their normal body. Keep the body you gave them in a player variable and compare it with current_player.biped, as the example does, to change each new life once. Creating a body on every tick runs out of objects.
  • Refused: a dead player, a target that isn't a living, unattached character, one with a mind of its own, or one another player controls. A no_object target does nothing.

Options, traits and zones

Options hosts can change, trait sets and zones are defined in a template, a JSON game type, and the script reads them by number. In the Zone Traits template, the script part holds:

Part of megalo-zone-template.json
"options": {
  "goal": {"value": 30, "min": 1, "max": 100},
  "interval": {"value": 1, "min": 1, "max": 10}
},
"megalo_traits": [{"speed": 7, "aura": 4}],
"objects": {
  "hill": {
    "source": {"kind": "zone"},
    "position": [0, 0, 0],
    "label": "objective",
    "shape": {"kind": "sphere", "radius": 8}
  }
}
  • Options: script_option[0] is the first option in alphabetical order, so here goal is 0 and interval is 1; an option added earlier in the alphabet moves the others up. Scripts read options as whole numbers. Hosts change them under Game Options → Slayer rules → Custom mode options.
  • Traits: script_traits[0] is the first set in megalo_traits, up to 16 sets. Values are positions in Game Options' list, not percentages: speed 7 is 125%, aura 4 is white; the trait table lists every trait. Trait sets last one tick: apply them on every tick the player should have them. Two sets in one tick combine, the later one winning where both set a trait.
  • Zones: loop over them by label and test current_object.shape_contains(current_player.biped). A zone is invisible and has no collision; spheres, boxes and cylinders work, as in version 2. Labels come from the template's objects or from objects the script creates; the labels of Forge objects on the map aren't read.

HUD widgets

Four rows, script_widget[0] to script_widget[3], show text and an optional bar. They start hidden.

Part of a script
script_widget[0].set_text("Progress: %n", global.number[0])
script_widget[0].set_value_text("of %n", 30)
script_widget[0].set_meter_params(number, global.number[0], 30)
script_widget[0].set_visibility(all_players, true)
CallWhat it does
set_text(text, …)The row's text. %n puts in a number, up to three; %% is a percent sign.
set_value_text(text, …)More text after it, the same way; "" removes it.
set_meter_params(number, value, maximum)A bar filled to value out of maximum. set_meter_params(none, 0, 0) removes it.
set_visibility(all_players, true)Shows the row to everyone, or hides it with false. Use current_player to show or hide it for one player.

A row's text and bar are the same for everyone who sees it; to show each player something different, use version 2's hud action. Numbers are put in when the call runs, so call it again when they change, as Timed Score does every tick. Text and value text together fit 96 characters. Players who join mid-game get the rows too.

Objects, attachments and physics

Any object reference works with these: a player's biped, an object in a loop, or one the script created.

CallWhat it does
place_at_me(type, label, flags, x, y, z, none)Creates an object at this one and returns it. See below.
attach_to(other, x, y, z, relative)Sticks it to another object, offset by −128 to 127 tenths of a Forge unit. relative follows the other object's facing, absolute the map's. Detach before attaching it elsewhere.
detach()Lets it go where it is.
copy_rotation_from(other, true)Turns it to face as the other does: true fully, false heading only. Not while it's attached.
set_hidden(true)Hides it, for every player.
set_invincibility(true)Makes it take no damage. Kill boundaries still remove it.
set_garbage_collection_disabled(true)Stops the game from cleaning it up when it lies around. Invincibility alone doesn't.
set_scale(50)Its size in percent, from 10 to 300.
set_velocity(x, y, z)Sends it moving, in Forge units a second, −100 to 100 each way. Halo 3 only.
move_to(x, y, z)Puts it at a place on the map, in Forge units. Detach it first. Halo 3 only.
delete(), kill(false)Removes it, or destroys it as damage would. kill(true), a silent kill, isn't available.
get_distance_to(other), get_speed()Distance in tenths of a Forge unit, that is in feet; speed in feet a second.
get_carrier(), health, shieldsThe player holding it, or no_player; its health and shields in percent, to read.
has_forge_label("gift"), shape_contains(other)Tests for a script label, and whether another object is inside this zone.

Creating objects

  • type is a weapon name such as shotgun (the weapon list), a character, or the name of a weapon, character or map placement in the template's objects. The map must have that weapon; a placement copy needs its original on the map, and has its standard settings, not every Forge setting.
  • label is a text or number for for each object with label, or none. flags is none, absolute_orientation or never_garbage_collect, one at a time. The offsets are in tenths of a Forge unit, −128 to 127, along the object's heading. The last value is always none.
  • Up to 256 created objects at a time: delete the ones you're done with. Created objects are removed when the round ends, except bodies players are in, and sizes, hiding and invincibility go back to normal.
  • An attached object can sit where a loose one can't survive: check where things land when you detach them on your map.

The Object Controls example puts it together:

megalo-object-controls.rvt
-- Compile directly with mode-compile; no JSON object bindings are required.
-- Each life gets a miniature shotgun, attached to the player for five
-- seconds before it drops into the world. Halo 3 must supply the weapon tag.
alias gift = player.object[0]
alias previous_body = player.object[1]
alias stage = player.number[0]
alias release_time = player.timer[0]
declare player.timer[0] = 5
declare global.timer[0] = 60

on init: do
    global.timer[0].set_rate(-100%)
end

for each player do
    if current_player.is_not_respawning() then
        if current_player.previous_body != current_player.biped then
            current_player.previous_body = current_player.biped
            current_player.stage = 0
        end
        if current_player.stage == 0 then
            current_player.gift = current_player.biped.place_at_me(shotgun, "gift", never_garbage_collect, 0, 0, 5, none)
            current_player.gift.set_scale(50)
            current_player.gift.set_invincibility(true)
            current_player.gift.attach_to(current_player.biped, 0, 0, 5, relative)
            current_player.release_time.reset()
            current_player.release_time.set_rate(-100%)
            current_player.stage = 1
        end
        if current_player.stage == 1 and current_player.release_time.is_zero() then
            current_player.gift.detach()
            current_player.gift.set_invincibility(false)
            current_player.gift.set_velocity(0, 1, 0)
            current_player.stage = 2
        end
    end
end

global.number[0] = 0
for each object with label "gift" do
    global.number[0] += 1
end
script_widget[0].set_text("Scripted gifts: %n", global.number[0])
script_widget[0].set_visibility(all_players, true)
if global.timer[0].is_zero() then game.end_round() end

Units

Megalo scripts and version 2's JSON measure some things differently. Check before copying a value from one to the other:

QuantityVersion 2 JSONMegalo script
SizeFactor: 0.5 is half sizePercent: 50 is half size
Timer counting downRate -1set_rate(-100%)
Distanceobject.distance in Forge unitsget_distance_to in tenths of a Forge unit
Placing objectsPositions on the mapOffsets from an object, in tenths of a Forge unit
NumbersFractions, within ±1,000,000Whole numbers that wrap around

One Forge unit is ten feet, so a tenth of a Forge unit is a foot.

Limits

LimitValue
Script file128 KB; the compiled mode's whole script, with its definitions, 32 KB
Nesting32 levels in the file, 64 calls and blocks while running
Steps per tick65,536
Created objects256 at once
World changes waiting at once256
Players, trait sets, options, named objects16, 16, 32 and 64
HUD rows4, 96 characters each
AttachmentsChains of up to 32 objects

When a tick goes wrong, such as a division by zero, a value out of range or too many steps, that tick's script changes are dropped and the script stops until the next round. The host's log says why. World changes already made by earlier ticks stay. A loop over every object on a busy map can reach the step limit even when each call is fine.

Not supported

The compiler refuses these rather than skipping them:

  • Compiled Reach or Halo 4 game variants (.bin, .mglo), loading or saving them, and MegaloEdit's syntax.
  • Events other than init and every tick: pregame, object death, local, local init and host migration.
  • Sounds, incidents and medals, objective waypoints and icons, timers shown live on the HUD, and the remaining game properties.
  • Setting health or shields, arbitrary damage, the remaining vehicle and spawn calls, silent kills, and combined creation flags.
  • Labels of Forge objects on the map, and anything only Reach or Halo 4 has.

Like every game mode, a Megalo script can't reach files, the network, other programs or the console.

Troubleshooting

ProblemWhat to do
Halo 3 Megalo adapter does not implement: event.pregameThe script uses something Project Reclaimer can't do on Halo 3. Remove or replace it, even in a part that never runs. megalo-check lists everything a script needs.
… does not implement: option:0 or traits:0The script reads an option or trait set that doesn't exist. Compile with the right --template, and count options in alphabetical order.
Unsupported Megalo API: Game.play_sound_forThat call isn't part of what's supported.
Global.Number[12] exceeds Reach variable capacityThere are fewer slots of that kind; see the variables table.
Timer rate must be a constant percentageWrite the rate as a value, such as -100%, not a variable.
3:1: Expected "end", found ""A block isn't closed. The numbers are the line and column where the compiler noticed.
Cannot create …: The file exists.The compiler doesn't overwrite files. Pick a new --output name, or move the old file away.
The score goes up every momentCode outside on init runs on every tick. Give points behind a timer or a change, as Timed Score does.
A timer fires only onceCall reset() and set_rate(-100%) again when it reaches zero.
Traits flicker or don't stayApply the trait set on every tick the player should have it.
A HUD row is empty or out of dateCall set_visibility, call set_text again when its numbers change, and keep the text within 96 characters.
Nobody is ever in the zoneGive the zone a position on your map and a size that fits; it has no visible edge.
An object can't be createdThe map may not have that weapon, or the script has 256 created objects already. Delete the ones it's done with.
An invincible object disappearsThe game cleans up objects lying around: set_garbage_collection_disabled(true). Kill boundaries still remove it.
My changes don't show in the lobbyCompile to a new file, choose Refresh, select the mode again and start a new game.
The script stopped during a roundLook in the host's log for Custom game mode stopped and the reason. The next round starts the script again.

The client's log is Documents\My Games\Project Reclaimer\Logs\client.log; a dedicated server's is data/<server>/server.log. When you report a problem, include the .rvt, the compiled .json, the map and what happened.