Tune
Change any number: gravity, jets, armor, weapon speed and damage, equipment.
{ "movement":
{ "gravity": 12.0 } }
defs.json
Mods · make it yours
Lower the gravity. Double every capture. Invent a mode. A mod is a folder of plain files: drop it in your server's content folder, and every player who joins gets it, after they say yes.
Old Holler at Dusk · in-game capture of a map variant
How modding works
The engine runs the physics, the weapons, flags, capture points and the ball. Everything a match plays by is data you can change, and you go only as deep as you need:
Change any number: gravity, jets, armor, weapon speed and damage, equipment.
{ "movement":
{ "gravity": 12.0 } }
defs.json
Make a new mode from any mode: clock, score limit, respawns, what's allowed.
{ "base": "ctf",
"score_limit": 5 }
modes/<key>.json
Decide what scores, what wins and what gets announced, in a short script.
fn on_frag(g, k, v) {
add_score(g.team(k), 2);
}
rules/<key>.rhai
Players never install anything by hand. The server lists what it runs, the game compares that with what it already has, and asks.
content/ beside server.json. --check proves them.In your server's content/mods/, make a folder named for the mod: lowercase letters, digits and hyphens, like moon-ctf.
Add mod.json with a name and a line about what it does:
{ "name": "Moon CTF", "note": "Low gravity, five captures.", "api": 1 }A defs.json for numbers, a modes/moon-ctf.json for a mode, or both:
{ "base": "ctf", "label": "Moon CTF", "short": "MOON",
"goal": "First to 5 captures", "score_limit": 5 }peakrunner-server --check reads it exactly as the server will and names every problem. Then peakrunner-server --setup asks which mods to run and offers your new mode. Done: it's live.
A mod holds only these files; anything else is refused. Each file is up to 64 KB.
content/
├─ maps/
│ └─ river-fort/ a map: map.json, its files, info.json
└─ mods/
└─ moon-ctf/
├─ mod.json name, note, api (required)
├─ defs.json only the numbers you change
├─ modes/
│ └─ moon-ctf.json a mode, varying another
├─ armors/ weapons/ packs/ … the engine's classes
├─ sounds/ short WAVE sounds
├─ rules/
│ └─ moon.rhai server only
└─ hooks/
└─ guardian.rhai server only
Everything in the game is an entry of one of the engine's classes: armors, weapons, projectiles, blasts, throwables, deployables, damage kinds, effects, packs, items and vehicles. A mod adds its own entries or changes the game's by id, one folder per class, and entries name the ones they use, so capabilities mix freely: put the disc on a turret, a drain orb on a gun, a plate on an armor, a shield in a pack.
// deployables/disc-turret.json: the base turret, firing discs
{ "disc-turret": { "extends": "turret", "name": "Disc turret",
"turret": { "range": 60, "sensed_range": 80, "speed": 95, "life": 5,
"cooldown": 1.6, "leads": true, "fires": true, "projectile": "disc" } } }
Armors get class keys (C) and mounts, packs are switched or used, effects shield, cloak or poison, maps add moving parts and zones, modes add a purse, and hooks (server scripts named by entries) answer what happens. The full reference, generated from the engine so it can't drift, is docs/mod-format.md in the server download; peakrunner-server --schema prints it.
defs.json names only what changes; everything else stays as the game ships it. There are sections for movement (gravity, jumps, speed limits, skiing and air control, the energy tank), armors (light, heavy, football), weapons (disc, chaingun (the Repeater), grenade launcher, mortar, laser, railgun, plasma gun, blaster), blasts, throwables, repair, equipment and deployables.
{ "movement": { "gravity": 12.0 },
"weapons": { "disc": { "speed": 120.0, "reload": 0.8 } } }
Every number must be from 0 to 1,000,000. A misspelled field is refused with the list of the right ones, so a typo never slips through.
A mode varies an existing one (ctf, capture_and_hold, team_deathmatch, rabbit, team_rabbit, football, duel, or another mode in your mod) and changes any of its fields. A variant of duel (heavy armor, first to 5) makes a duel server when a rotation names only it. The file name is the new mode's key.
| Field | What it does |
|---|---|
label, short, note, goal, start_message | What players see: menu name, list tag, description, HUD goal, the banner at the start |
clock, halves, warmup | Seconds per match (30–7200), one or two halves, first countdown |
respawn, suicide_delay | Seconds from death, and from Ctrl+K, to respawn |
score_limit | Captures, points, touchdowns or frags to win |
equipment, deployables, ammo_stations, throwables, loot, repair_tool, purchases | Switch whole systems on or off |
weapons | The guns allowed: any of disc, chaingun (the Repeater), grenade_launcher, mortar, laser, railgun, plasma, blaster, or none ([]). ["disc"] with throwables on is discs, grenades and mines. |
banned | Weapons and throwables taken out of play by id, whatever the rest allows. Rabbit bans ["mortar", "mine"]. |
conditions, armor_scaling, movement | Random weather and twists, armor damage scaling, skier or classic movement |
script | Rules from rules/<script>.rhai instead of the mode's own (below) |
Numbers and switches can't invent a new way to score. A rules script can. Name it in your mode ("script": "spree") and put it in rules/spree.rhai. The engine still runs every mechanic; the script answers what happens.
// Spree: every third frag in a row scores double.
fn on_frag(game, killer, victim) {
if !game.playing { return; }
if type_of(this.streaks) != "map" { this.streaks = #{}; }
this.streaks.remove(`${victim}`);
if !game.hostile(killer, victim) { return; }
let streak = (this.streaks[`${killer}`] ?? 0) + 1;
this.streaks[`${killer}`] = streak;
let bonus = streak % 3 == 0;
add_score(game.team(killer), if bonus { 2 } else { 1 });
if bonus {
feed(`${game.name(killer)} is on a spree!`);
sound("point");
}
}
Events in. The engine calls your handler when something happens: a capture, a frag, a touchdown, the clock running out.
Commands out. Your script answers with commands like add_score and end_match, checked before they count.
Memory. this lasts the round: streaks, counts, anything.
Server only. Scripts are never sent to players. Offline, a scripted mode plays the rules of the mode it varies.
Scripts are written in Rhai, a small language that reads like Rust or JavaScript. The base game's own rules are written this way too, and tested to match the engine's exactly.
A map is a folder in content/maps/<key>/: map.json, the files it lists (terrain, collision, textures, lighting, props) and an info.json with its menu name and description. Name its key in your rotation, and players who don't have it download it when they join. A map's identity is the fingerprint of its map.json, so change anything and it's a new map to everyone.


lookChanging a map's look (sun, sky, fog and exposure) in map.json is the quickest variant. New terrain comes from the map pipeline.
Define any of these; a missing one does nothing. Players are numbered from 0; teams are 0 (Ember) and 1 (Glacier).
| Handler | When |
|---|---|
on_flag_captured(game, player) | A flag reached home (the engine has returned it) |
on_touchdown(game, player) | The ball crossed the line |
on_carrier_tackled(game, winner, loser) | The ball carrier was tackled |
on_frag(game, killer, victim) | A player died; killer == victim for a suicide |
on_died(game, victim, killer) | Every death, after any on_frag; killer is -1 when nobody did it |
on_rabbit_grab(game, player), on_rabbit_held(game, player) | A player took the rabbit flag; has held it another 15 seconds in all |
on_zone_points(game, gained) | Held capture points earned [ember, glacier] this step |
on_clock_expired(game) | Time's up (if you don't end the match, the engine does) |
On game | Gives |
|---|---|
score, limit | [ember, glacier] and the mode's score limit |
playing, ffa, player_count | Whether the match is on, free-for-all, how many players |
team(i), frags(i), losses(i), name(i) | A player's team, frags, deaths and name |
is_you(i) | Whether the name is "You" (offline, the player at the screen), so a line can say "You score" rather than "You scores" |
hostile(a, b), leader(), team_name(t) | Whether a's shots hurt b; the top player (-1: none); "EMBER" or "GLACIER" |
touchdown_points, tackle_points | Football's personal points |
rabbit | Who holds the rabbit flag (-1: nobody) |
| Command | Does |
|---|---|
add_score(team, points), set_score(team, points) | Team score, 0 to 100,000 |
add_frags(player, n) | A player's personal points (negative takes away) |
end_match(text, seconds) | Ends the match with a banner (up to 80 characters) |
message(text, seconds), feed(text) | A center banner; a line in the play-by-play feed |
sound(name), shake(amount) | An announcer cue (capture_win, capture_loss, touchdown_win, touchdown_loss, point, contest, whistle, horn, halftime, go, flag, return, tackle, catch, intercept, end); screen shake 0–1 |
The server download has these in examples/mods/: copy one into content/mods/ and run --setup.
Gravity at 12 instead of 20, in every mode. One line.
defsFive captures, five-minute rounds.
modeFaster discs and repeater; Team Deathmatch with no mines or deployables.
defs modeStreaks score double; a suicide costs your team a point.
mode rulesA turret that fires discs, carried as a pack.
classesPatches the orb gun's orb: its lightning reaches farther and drains harder.
classesA heavy armor with a plate in front, a ward on C, and a hook.
classes hooksNine classes with class keys, gadgets, fliers, a team purse and advanced scoring.
classes modes hooksPlayers download JSON and map files only. A server can't make your game run anything.
Every file is matched to its fingerprint when it arrives, then loaded as the game loads its own maps.
Rules scripts run on the server, fenced in: no files, network or clock, limited work per event.
Nothing downloads until you choose to, and leaving puts your game back as it was.
Keep it original: build your own maps and mods rather than copying other games' material.