This is the objective asset for TMC's Dot collection. It adds the part of a multiplayer game that says what a round is about: control points, a bomb to plant and defuse, a payload to push, a flag to capture, somebody to escort out, and a clock to hold.
This collection of assets provides modular building blocks for creating games and applications within the TMC ecosystem, ensuring consistency and interoperability across all dot-* assets. This includes core functionality, networking, authentication, cloud integration, and more.
These assets are COMPLETELY OPEN SOURCE. You are free to use, modify, and distribute them under the terms of the MIT license. The only thing not open source is the back-end web infrastructure. So if you opt into using your own authentication backend instead of integrating with TMC, you will need to build and integrate your own back-end infrastructure.
This asset, along with all the others, was built initially with Claude Code and will continue to be maintained and extended using it. This is because I (gamemann) cannot build the entire TMC platform alone (I wish I could lol).
Please treat this as partially tested. Every asset has its own headless test suite and those suites pass, but very little of this has been in front of real players yet. Expect rough edges, and please report anything you run into.
I intend on reviewing code, testing, and editing documentation regularly. If you're interested in helping out, please let me know!
An objective is what a team is trying to do in a round, other than kill the other team. There are six kinds:
| Kind | What it is |
|---|---|
CAPTURE |
A control point. Stand on it long enough and your team owns it |
BOMB |
Plant a bomb at a site, and the other team defuses it before the fuse runs out |
PAYLOAD |
A cart that moves along a path while the attacking team stands next to it |
FLAG |
Take the other team's flag back to your own base |
RESCUE |
Lead somebody (or several people) to an exit |
HOLDOUT |
Hold a point, or just survive, until a clock runs out |
The manager runs every objective on the map each tick: who is standing where, how far a capture or a cart has got, whether a defuse was interrupted. It tells your game when something is started, interrupted, blocked or completed. It never ends a round itself. A game with none of these is a deathmatch, which dot-match handles on its own.
The numbers follow what objective modes have settled on over the years:
- More players capture faster, but each one adds less. One player takes 480 ticks, two take 320, three take 263.
- Both teams on a point pauses it. A defender only gets credit for blocking if the capture was at least half done.
- A capture left alone drains away over 90 seconds and does not snap back, six times faster in overtime.
- A cart moves at 0.55, 0.77 and 1.0 of full speed with one, two, and three or more pushers. A downhill part of the path rolls it with nobody pushing.
You need Godot 4.7. The easiest way to get this addon and the ones it needs is dot-bootstrap, which clones every project and links the addons into each one.
To add it to your own project by hand, copy addons/dot_objective/ and dot-core's addons/dot_core/ into it and enable dot-objective in Project → Project Settings → Plugins. dot-core is the only dependency.
var objectives := DotObjectiveManager.new()
objectives.presence = DotObjectivePresence.of(
roster.keys, # () -> PackedStringArray
world.position_of, # (key) -> Vector3
match_node.team_of, # (key) -> int, teams start at 1
world.is_alive # (key) -> bool
)
objectives.score_fn = func(id, team, by, points, player_points):
match_node.report_objective(by, id, tick, points)
var res := objectives.setup(map.objectives) # an Array[DotObjectiveDef]
if not res.ok:
push_error(res.error.message)
add_child(objectives)
objectives.set_live(true) # when warmup or freeze time ends. Nothing moves until you do
objectives.advance(tick) # once per tick
objectives.reset_round() # between roundsNothing makes progress until set_live(true). This is on purpose, because during warmup everybody is standing on everything. Turn it off again for freeze time and the end of a round.
An objective is a DotObjectiveDef, and each kind has a factory:
DotObjectiveDef.capture(&"mid", DotObjectiveArea.cylinder(Vector3.ZERO, 4.0))
DotObjectiveDef.bomb(&"site_a", DotObjectiveArea.box(site_centre, Vector3(4, 2, 4)))
DotObjectiveDef.payload(&"cart", path_points) # then set capturable_by to the pushing team
DotObjectiveDef.flag(&"red_flag", DotObjectiveArea.sphere(red_base, 2.0), 1)
DotObjectiveDef.rescue(&"hostages", start_points, DotObjectiveArea.sphere(exit, 3.0))
DotObjectiveDef.holdout(&"radio", 10800)Set capturable_by to limit which teams may act on an objective (a bomb site only one side plants at), initial_team for who owns it at the start, and requires_owned to make a point lockable until a team owns the one before it. A payload must have a pushing team, from capturable_by or initial_team, or nobody can push it.
Captures, carts and holdouts run on their own from where players stand. The other kinds need your game to say what a player did, because only it knows about items and buttons:
- Bomb:
give_to(key),drop_at(position)when the carrier dies,try_pick_up(key, presence),begin_plant(key, presence)andbegin_defuse(...). A plant or defuse is cancelled by itself if the player dies, leaves the site or moves away. - Flag:
touch(key, presence)to pick it up or return it,drop(position). A carrier reaching their own base scores. - Rescue:
take(id, key, presence),release(id),lose(id).
Get the kind's object with objectives.objective(&"site_a"). Signals: objective_started, objective_progressed, objective_interrupted, objective_blocked, objective_completed, objective_event, all_captured and round_objective_met.
A client mirrors the server's state instead of running it:
mirror.authoritative = false
mirror.setup(map.objectives)
mirror.apply_wire(wire_from_the_server) # the server sends objectives.to_wire()The rules live in a DotObjectiveRules resource, set on the manager's rules (it makes a default one if you do not). Like every Dot config it is layered: inspector defaults, then a JSON file, then the environment, then the command line. Call load_layered() on it before setup() to apply them:
var rules := DotObjectiveRules.new()
rules.load_layered("user://objectives.json")
objectives.rules = rulesDOT_OBJECTIVE_BLOCK_STYLE=0 godot --headless -- --objective-deteriorate-ticks=3840| Setting | Default | What it does |
|---|---|---|
block_style |
1 | Both teams on a point: 0 resets the capture, 1 pauses it |
deteriorate_ticks |
5760 | How long an abandoned capture takes to drain (90 seconds at 64 ticks) |
overtime_decay_multiplier |
6.0 | How much faster it drains in overtime |
block_credit_fraction |
0.5 | How far along a capture must be for a blocker to get credit |
capture_recovery_ticks |
0 | Wait after a broken capture before it can start again |
payload_speed_one / _two / _three |
0.55 / 0.77 / 1.0 | Cart speed for one, two, and three or more pushers |
payload_recede_speed |
0.1 | How fast an idle cart rolls back |
payload_recede_overtime_ticks |
320 | Idle time in overtime before it rolls back (5 seconds) |
payload_defenders_block |
true | A defender next to the cart stops it |
live |
false | Whether anything makes progress. Use set_live() |
overtime |
false | Overtime is on. Use set_overtime() |
Each objective's own timings (capture time, plant, fuse and defuse times, cart speed, flag return time, holdout length) are fields on its DotObjectiveDef.
godot --headless --path . --import
godot --headless --path . res://examples/objective_selftest.tscnMIT. See LICENSE.