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:

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_global write, and api.add_player, api.add_team, api.add_global add (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_spawned when 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_seconds is 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_id is 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")
  • team is 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 _tick that 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.

TeamSpec Level Editor
Basics Getting started · Interface · Viewport and camera · Selecting and hiding · Keyboard shortcuts · Settings · Map Config · Troubleshooting
Menus File · Edit · View · Tools · Functional
Panels Scene tree · Inspector · Materials · Props · Snapping · UV editor · Displacement · Terrain
Tools Move, Rotate and Scale · Box · Sphere · Cylinder · Prism · Stairs · Spiral Stairs · Spiral Tower · Arch · Path Brush · Plane · Terrain · Clip · Duplicate · Ruler and Cordon · Edit Vertex and Edge · Edit Face · Loop Cut · Material Brush · Vertex Color Brush · Displacement · Prop Path
Entities Brush · Zone · Group · Mirror Node · Prop · Spawn Point · Supply Pickup · World Boundary · Lights · Water · Sounds · Anchor Platform · Payload Track · Boss Spawn · Map Marker · World Text · Decal · Door · Button · Logic entities · 3D Skybox · Nav Region
Workflows Your first map · Brushes and CSG · Materials and UVs · Lighting and lightmaps · Bots and navigation · Mirrored maps · Outputs and logic · Custom assets · Playtesting · Exporting and publishing · Importing Source maps
Scripting Overview · Server rules · Client HUD · Bots · Teams and spawning · Zones · Pickups · Payload tracks · Map entities · Sandbox limits · Testing · King of the Hill · Team Deathmatch · API reference · What a script may use