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-compilechecks 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.rvtis 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
- 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.
-
Open a terminal there
In File Explorer, right-click inside the client's folder and choose Open in Terminal. Type
.\projectand press Tab for the program's name. -
Compile it into your Game Modes folder
It answersPowerShell
.\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"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. - 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.
| Command | What it does |
|---|---|
megalo-check script.rvt | Reads 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.json | The 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.json | Checks 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
.jsonincontent/Game Modesand 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
-- 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:
.\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 to | Numbers | Objects | Players | Teams | Timers |
|---|---|---|---|---|---|
global | 12 | 16 | 8 | 8 | 8 |
object | 8 | 4 | 4 | 2 | 4 |
player | 8 | 4 | 4 | 4 | 4 |
team | 8 | 6 | 4 | 4 | 4 |
temporaries | 10 | 8 | 3 | 6 | 0 |
- 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]. declareis only needed for a starting value:declare player.number[0] = 3. Numbers and timers otherwise start at 0, references atno_object,no_playerorno_team.with network priority loworhighis accepted and ignored;localisn't accepted.alias points = player.number[0]gives a slot a name.alias scratch = allocate temporary numberorallocate global.numbertakes 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_playerorno_object, never someone or something else. A player's variables stay through respawns, and every round starts fresh. - Inside loops,
current_player,current_objectandcurrent_teamare the one being visited;team[0]toteam[7]andneutral_teamare 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.
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.
| Loop | Goes through |
|---|---|
for each player do | Every player in the game, dead ones too. Check current_player.is_not_respawning() for the living. |
for each player randomly do | The same players in a shuffled order. |
for each team do | The 8 teams and the neutral team, empty ones too. |
for each object do | Every object in the world: players' bodies, the map's objects, the script's own and its zones. |
for each object with label "gift" do | The objects with that script label. |
Comments start with --. do … end makes a block of its own.
Events and timers
on init: do…endruns 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 deathorlocal, 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 callset_rateagain 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 team | What it does |
|---|---|
current_player.biped | The player's body, or no_object while they're dead. |
current_player.is_not_respawning() | True while the player is alive. |
current_player.score | Their 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.team | Their 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].score | The 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:
-- 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 multiplayerspartanandelite. - 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.bipedis 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_objecttarget 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:
"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 heregoalis 0 andintervalis 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 inmegalo_traits, up to 16 sets. Values are positions in Game Options' list, not percentages:speed7 is 125%,aura4 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.
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)
| Call | What 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.
| Call | What 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, shields | The 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
typeis a weapon name such asshotgun(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.labelis a text or number forfor each object with label, ornone.flagsisnone,absolute_orientationornever_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 alwaysnone.- 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:
-- 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:
| Quantity | Version 2 JSON | Megalo script |
|---|---|---|
| Size | Factor: 0.5 is half size | Percent: 50 is half size |
| Timer counting down | Rate -1 | set_rate(-100%) |
| Distance | object.distance in Forge units | get_distance_to in tenths of a Forge unit |
| Placing objects | Positions on the map | Offsets from an object, in tenths of a Forge unit |
| Numbers | Fractions, within ±1,000,000 | Whole numbers that wrap around |
One Forge unit is ten feet, so a tenth of a Forge unit is a foot.
Limits
| Limit | Value |
|---|---|
| Script file | 128 KB; the compiled mode's whole script, with its definitions, 32 KB |
| Nesting | 32 levels in the file, 64 calls and blocks while running |
| Steps per tick | 65,536 |
| Created objects | 256 at once |
| World changes waiting at once | 256 |
| Players, trait sets, options, named objects | 16, 16, 32 and 64 |
| HUD rows | 4, 96 characters each |
| Attachments | Chains 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
initand every tick:pregame,object death,local,local initand 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
| Problem | What to do |
|---|---|
Halo 3 Megalo adapter does not implement: event.pregame | The 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:0 | The 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_for | That call isn't part of what's supported. |
Global.Number[12] exceeds Reach variable capacity | There are fewer slots of that kind; see the variables table. |
Timer rate must be a constant percentage | Write 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 moment | Code outside on init runs on every tick. Give points behind a timer or a change, as Timed Score does. |
| A timer fires only once | Call reset() and set_rate(-100%) again when it reaches zero. |
| Traits flicker or don't stay | Apply the trait set on every tick the player should have it. |
| A HUD row is empty or out of date | Call set_visibility, call set_text again when its numbers change, and keep the text within 96 characters. |
| Nobody is ever in the zone | Give the zone a position on your map and a size that fits; it has no visible edge. |
| An object can't be created | The map may not have that weapon, or the script has 256 created objects already. Delete the ones it's done with. |
| An invincible object disappears | The game cleans up objects lying around: set_garbage_collection_disabled(true). Kill boundaries still remove it. |
| My changes don't show in the lobby | Compile to a new file, choose Refresh, select the mode again and start a new game. |
| The script stopped during a round | Look 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.
