Level Editor/Scripting/Server rules
server.gd is the authority on your mode. It starts with extends GamemodeServerScript, runs only on the server, and does everything through api, a GamemodeServerApi. Every hook it can override is on GamemodeServerScript.
Counters
Counters are your mode's numbers: a score, a timer, how many souls a player is carrying. They are the only state the server sends to the players, so anything the HUD shows must be a counter. Every player's machine receives every counter.
There are three kinds:
- Player counters, one value per player: api.declare_player_counter.
- Team counters, one value per team: api.declare_team_counter.
- Global counters, one value for the match: api.declare_global_counter.
Declare every counter in _declare(), with a name, an initial value, a minimum and a maximum:
func _declare() -> void: api.declare_team_counter(&"score", 0, 0, 100) api.declare_player_counter(&"carrying", 0, 0, 5) api.declare_global_counter(&"time_left", 300, 0, 300)
Then read and write them by name:
api.get_player(entity_id, name),api.get_team(team, name),api.get_global(name)read.api.set_player,api.set_team,api.set_globalwrite, andapi.add_player,api.add_team,api.add_globaladd (a negative amount subtracts).- Counters hold whole numbers only.
- Every write is held between the counter's minimum and maximum, and returns the value actually stored. The minimum defaults to 0, so a counter cannot go below zero unless you declare a lower minimum.
- api.transfer_player and api.transfer_team add as much as fits and return how much really moved. Always use the return value: banking 5 into a bank with room for 2 moves 2.
- Writing a counter that was never declared does nothing (it prints a warning and returns 0), and reading one returns 0. A misspelt name shows up as a counter stuck at zero.
- New players start at the declared initial values. A player's counters keep their values through a death; call api.reset_player_counters in
_player_spawnedwhen dying should cost them. - api.counter_maximum reads a declared maximum back, so other code never has to repeat the number.
Deaths and respawns
When a player dies, _player_died(entity_id, killer_id, default_seconds) runs, and they stay dead until your script respawns them. The usual hook is one line:
func _player_died(entity_id: int, _killer_id: int, default_seconds: float) -> void: api.respawn_in(entity_id, default_seconds)
default_secondsis the player's normal respawn time. Pass a different number for a different rule, such as faster respawns for the losing team, or do not call api.respawn_in at all for an elimination round.killer_idis the player who gets the kill, or -1 when nobody earned it: a suicide, api.kill, or falling out of the world. A death from a fall or a hazard shortly after somebody hurt them counts for whoever hurt them last.- api.death_position is where they died. Drop things there, not at api.position_of, which is where the body is by the time the hook runs.
- Every death also drops a medium ammo pack where the player died. That is the game's rule, not the mode's.
_player_spawned(entity_id)runs when they come back. It does not run for the first spawn after joining.- When they come back, the game places them at
_spawn_position(entity_id). See Teams and spawning.
Winning
api.declare_winner(team, "held the hill for 60 seconds")
teamis the winner, or 0 for a draw. The text is optional: it is printed on the result screen under the winning team, so keep it short.- Only the first call counts, so a
_tickthat keeps finding its win condition true does no harm. - After it, the round is decided: your zone, pickup and tick hooks stop running at once, the world plays on for five seconds so everyone sees the winning blow land, then it freezes while the result screen is up and players vote for the next map. api.match_state tells you which phase the match is in, for anything else you run.
- On every player's machine, the client script hears about it in
_match_ended(team).
Ticking
_tick(delta) runs once per server tick with the seconds since the last one. Put timers and win checks here. A timer is a float you add delta to:
var _clock: float = 0.0 func _tick(delta: float) -> void: _clock += delta while _clock >= 1.0: _clock -= 1.0 api.add_global(&"time_left", -1)
Telling players something
api.notify, api.notify_team and api.notify_all flash a message across the top of the screen, under your HUD widgets, for a few seconds: a title, an optional shorter line under it, and warn for something the player loses by ignoring (drawn in the warning color and held a little longer). One message shows at a time, and a new one replaces whatever is up, so send what matters right now (a checkpoint reached, your boss under attack), not every hit. Bots are skipped, so looping over every player is fine.
Acting on players
- api.teleport moves a player.
- api.kill kills a player for one of your own hazards. Nobody gets the kill.
- api.resupply refills health, ammo and reserve, exactly like a respawn does.
- api.set_spawn_immunity makes a player immune to damage and keeps their reserve ammo topped up until you turn it off. Healing still works.
- api.set_class_change_allowed lets a living player change class where they stand. A player's first pick, and any pick made while dead, always works.
- api.set_spectating takes a player out of the round without disconnecting them, and puts them back.
The usual spawn room uses the last three together; see Teams and spawning.
_spawn_position only reads
_spawn_position runs while a player is being placed, apart from the other hooks. Read only through api there and return a position: don't write counters, and don't read or change variables of your own. Everything a spawn rule usually needs is readable through api: the player's team, spawn points, zones, and how far a payload cart has come.
Printing
api.log prints a line tagged with your map's name. print also works. See Testing a mode for where to read it.
Keeping your own state
Plain variables on the script keep their values for the whole match, so a Dictionary keyed by entity id is the usual way to remember something about each player. Forget players in _player_left. Remember that Dictionary.get is not available: use has and [], or get_or_add. See Sandbox limits.