CaptureTheFlag
Two-team Capture the Flag for STRAFTAT. Continuous respawns, host-authoritative rules, first to three captures takes the round.
By doms
CHANGELOG
Changelog
1.0.4
Explosives, a respawn that fired at people who did not need one, and the first real answer to where players should come back.
Explosives work again for clients
PauseManager.BetweenRounds is set by InvokeBeforeSpawn and cleared by exactly one thing:
InvokeRoundStarted, which only the round-intro coroutine calls — and CTF suppresses that
coroutine on every respawn. So during a CTF match the flag is a one-way latch. Anything that sets
it, stays set.
PhysicsGrenade checks it after marking itself exploded, so a latched match does not delay
grenades, it disarms them permanently. DualLauncher and WeaponHandSpawner check it too.
It hit clients and not the host because of PlayerManager.OnLoadSceneEnd, whose early return is
guarded on SpawnedObject — a private field only ever assigned inside SpawnPlayer, which runs
server-side. On a client it is null for every player forever, so the guard never fires and the
event is raised on every FishNet scene load, including the ones a joining player causes.
The same event is destructive in its own right mid-match: every live explosive despawns
(PhysicsGrenade, Obus, Claymore, ProximityMine, Bubble, PredictedProjectile and
DamageZone all subscribe a despawn to it), the map's animators rebind and switch off, every
weapon spawner's countdown resets to zero, and every living player has canMove set false.
It is now suppressed during a match, on the same gate as the round-intro suppression, so the map's
own opening one still runs and still arms everything. Because that method is 22 IL bytes — the
same size as InvokeRoundStarted and close enough to Mono's inlining limit not to trust alone —
the flag itself is also re-asserted every frame, on every machine, by a check that cannot be
inlined past. It writes the field directly rather than raising the round-started event, because
that event is what rebinds the map animators and must not fire per respawn.
Living players are no longer respawned out from under themselves
CtfGame.IsAlive consulted PlayerHealth.isKilled, and that field is asymmetric:
[SyncVar(WritePermissions = ClientUnsynchronized)], set true through ChangeKilledState — a
ServerRpc, so it is written on the server and replicated — but only ever cleared by local writes
on the owning client, which by definition never travel back. On the host it can go true and never
false while a body lives.
Explosives are the only damage source that touches it. The grenades, claymore and obus set it on
everything in the blast before applying damage; the ones that do guard it test
health - damage <= 0 against a replicated health value read on the attacker's machine, which is
stale for a body that has just spawned. Either way a survivor gets latched, the host reads them as
dead, and the catch-up audit respawns somebody who was alive and well — most visibly around spawn
points, where the freshly spawned bodies are.
Aliveness is now decided on the body: health above zero, on an object that is present and switched
on. Walking every death path in the game confirms health is the honest signal on the host — the
weapons all assign -8 through a KillServer ServerRpc, the explosives go through RemoveHealth,
and the void, the out-of-bounds volume, the -300 floor and a lethal damage zone all go through
FirstPersonController.DespawnObject, another ServerRpc that assigns -8. The owning client's local
write of -2000 afterwards never travels, so it cannot mislead the host either way.
The same walk turned up a second, larger version of the fault. CTF was treating removal from the
game's alive set as proof of death, and it is not: PlayerDied is a
[ServerRpc(RequireOwnership = false)] that any client can raise for any player, the owner-side
death handler can re-enter it if a health value re-syncs after it has run, and ClientInstance
raises it from OnDisable. One spurious report was enough to make the host treat a player who was
walking around as a corpse — and not only by respawning them. The flag they were carrying was
dropped on the spot, and they were skipped by every flag rule, so walking over a flag did nothing.
The combined "is this player alive" test is gone entirely, because its two halves answer different questions and requiring both was the bug. Rules now ask whether the player has a living body; the alive set is consulted only to notice that it disagrees, and when it does — someone standing there, switched on, with health, listed as dead — CTF corrects the set and leaves the player alone.
The audit is slower to fire as well: a player must read as bodyless on two sweeps 1.5s apart, and a body less than 2.5s old is left alone entirely. Everything that audit can get wrong, it gets wrong by taking a living player and dropping them somewhere else, so it should be the last thing to act.
Respawn placement
STRAFTAT picks a spawn point by shuffling them and returning the first free one, where "free" is
Physics.OverlapSphereNonAlloc(position, radius, null, 5) — a null results buffer, which can only
ever report zero hits. The occupancy test is dead code and the result is uniform random. Under
elimination that is fine, because everyone spawns at once at the start of a take. Under continuous
respawns it means coming back inside the enemy base, on the flag you were about to defend, or in
front of the person who just killed you.
CTF now chooses, host-side, in two stages. Candidates are first rated by the worst thing wrong with
them — somebody standing there or just sent there, live ordnance in blast range, an enemy with line
of sight — and only the least-bad tier is considered, so where vanilla gives up and returns
CurrentSpawnPoints[0], this still prefers being seen to being telefragged. Within that tier they
are scored on which half of the map they are on, room from enemies and teammates, how recently
somebody died or spawned there, and where the flags currently are. The winner is then drawn from
the best few rather than taken outright: a spawn should be reliably good without being knowable in
advance, because a predictable spawn is a camped one.
By default it considers every spawn point in the map rather than the group the lobby size selected. A two-player lobby is otherwise choosing between two points, which is not a choice.
It is a postfix that swaps the result, so the game's own answer is still made and still stands
whenever CTF declines — an unusable map, the feature switched off, or the hook itself missing,
which is now a warning rather than a reason to refuse to load the mode. SmartRespawns = false
restores STRAFTAT's placement exactly.
New config section Respawns: SmartRespawns, RespawnUsesAllSpawnPoints,
RespawnEnemyClearance, RespawnHazardRadius, RespawnVariety.
1.0.3
Three faults that only a second player can produce. 1.0.2 was verified solo, and solo is structurally blind to all of them: with one player the host is everyone.
Respawning no longer re-dresses everyone in the host's outfit
PlayerManager.CmdRespawn reads the suit and cigarette from whatever machine executes it, because
in vanilla its only caller is the player's own client. CTF runs every respawn on the host, so each
time anyone else came back they came back wearing the host's cosmetics — on every death, for the
whole match. Their hat was unaffected, which would have made it read as a rendering glitch rather
than a spawn bug.
The respawn now briefly points the host's cosmetics at what that player is actually wearing, read
off their current body, and restores it in a finally. The invoke is synchronous, so the swap is
never observable.
A slow second client no longer cuts the map intro short for everybody
AuditForMissingPlayers books a catch-up respawn for anyone on a playing team with no body. A
client still loading the map has no body yet, and the audit starts four seconds after CTF goes
live, so a client a few seconds behind the host got "caught up" into their own arrival. The
respawn that followed called ClearStartCountdown, which is an observers RPC: it collapsed the
opening countdown for the entire lobby and let everyone move before the intro had played.
Nobody counts as missing until the map's own opening round start has run, and ClearStartCountdown
refuses to fire before that point regardless of who asks. A respawn that early needs neither: the
host's opening round start publishes a start time to everyone a moment later anyway.
1.0.2
Fixes the reason 1.0.1's round-flow work never took effect. Every game hook was applied, verified and then skipped at runtime, so a death still ended the take and nobody was ever respawned.
The hooks were switched off by a null check
All six Harmony patches ask CtfPlugin.ModeActive whether CTF owns the round before they do
anything. That property began Instance != null, and CtfPlugin is a UnityEngine.Object.
STRAFTAT destroys the loader-owned plugin component during startup — the mod is built to survive
that, which is why the networking receiver is a plain object and the per-frame work lives on
CtfRuntime. Both reach the plugin through ?., which tests the reference and therefore keeps
working. Unity's overloaded ==, however, reports a destroyed component as null even while the
managed instance is alive; ModeActive used that operator and answered false for the whole
session.
So the mode ran matches, flags, pickups, captures, scores and the HUD, while every rule that depends on the game's own systems quietly did nothing:
- the elimination coroutine was never suppressed, so the first death ended the take — with the capture counts mirrored into the native take score, that ended the STRAFTAT round and loaded the next map a kill or two later;
- no death ever scheduled a respawn, so continuous respawning had in fact never once run;
- the round-start countdown was neither collapsed nor skipped for respawns, so a death that restarted the take also replayed the full take intro.
ModeActive now compares the reference itself. The component being destroyed is also logged at
info level rather than debug, because its absence from ordinary logs is what hid this.
Version
Bumped so the handshake refuses to mix a fixed host with a 1.0.1 client: the two play the round under different rules.
1.0.1
Fixes the bug that made 1.0.0 unplayable: a team wipe ended the take instead of respawning, and captures came and went with it.
A wiped team no longer restarts the take
GameManager.WaitForDraw is what ends a take once only one team is left standing, and CTF has
always patched it to do nothing. That patch applied cleanly and never ran. The method is an
iterator, so its compiled body is fourteen bytes — allocate the state machine, return it — which is
under Mono's inlining limit, and the JIT copied it into its caller where no Harmony detour can
reach it. Waiting for draw... in the log next to a live CTF match was the mode being overruled by
the very method it thought it had switched off.
The suppression now sits on the state machine's MoveNext, which carries the whole body and is far
too large to be inlined. Every step of the coroutine passes through it, and the first one ends it
before a single statement has run. Recording a death also stops the coroutine outright rather than
just forgetting its handle, so a wipe cannot end the take even if the patch is ever bypassed again.
This was behind both reported problems. Each wipe restarted the take and awarded an elimination point, so the native round score climbed on its own and ended the STRAFTAT round after a couple of kills — taking the CTF score with it, which is what made captures look like they sometimes did not count. Between the restart and the round-start freeze that follows it, players were also ignored by the flag rules entirely, so touches during that window did nothing.
Captures no longer depend on where you were when a tick landed
Flag rules run a few dozen times a second, not every frame. A player leaving a teleporter or riding an explosion could be short of a base on one evaluation and past it on the next, having been well inside it in between, and score nothing. Pickup, return and capture tests now sweep the movement since the previous tick instead of sampling a single instant. A step longer than any run could cover is treated as a teleport and only its end point counts, so coming back to life across the map cannot collect flags along the way.
Housekeeping
- Every Harmony target is now checked for the inlining trap that hid this bug;
WaitForDrawwas the only one small enough to be at risk. - The version bump means a machine still on 1.0.0 is refused by the handshake rather than playing a match under different rules from everyone else.
1.0.0
First complete release. Capture the Flag is playable end to end: flags, bases, captures, continuous respawns, a HUD, sound, and a clean hand-off back into STRAFTAT's normal round and match flow.
Fixed the blocker that stopped 0.1.0 from ever running
The mod loaded but its networking was torn down before the game reached a Steam lobby, so no CTF match could ever start. The receiver now lives on a plain object with no Unity lifetime, and the per-frame runtime lives on its own persistent object that rebuilds itself if anything destroys it.
Rules
- Two teams, one flag and one base each; enemy touch takes a flag, owner touch sends it home.
- Death drops the carried flag where the carrier fell, settled onto the ground beneath them.
- Dropped flags return on a timer, immediately if they fall out of the world, and when their carrier disconnects or switches team.
- Nobody can carry both flags. Captures optionally require your own flag to be home.
- Killed players respawn on a timer instead of sitting out the take.
- The first team to the capture target wins the round through the game's own scoring, end-of-round screen and next-map load.
- Capture counts mirror into the game's own match-point display, and are put back exactly as found if CTF stands down mid-map.
Presentation
- Flags are built at runtime: waving cloth, dark metal pole, ground glow, and a light shaft so they can be found across a map. Dropped flags flash faster as their return timer runs out.
- Bases are pulsing rings that beat faster while their flag is stolen.
- Colours follow the ally and enemy colours from the player's own outline settings.
- HUD is TextMeshPro on its own canvas using the game's font: score, capture target, flag state, objective line, and centre-screen callouts. Off-screen markers point at the flags and your base.
- Sound cues are borrowed from the game's own audio and play through its effects mixer, so volume settings apply normally.
- CTF events are also written into STRAFTAT's own match log feed.
Compatibility and safety
- Every Harmony target is resolved and verified at load. A missing one logs a single clear line and leaves the game playing normally rather than half-patching a match.
- No patch reimplements a game method. The only suppressions are of behaviour CTF replaces.
- The map's first round start always runs, so weapon spawners, map interactables and animated geometry are armed exactly as in vanilla; only the intro for later respawns is skipped.
- Clients accept CTF state only from the Steam lobby host. Snapshots are bounds-checked field by field and rejected on any inconsistency.
- The host waits for every lobby member to report the same CTF version before starting.
- Configurable capture target, respawn delay, radii, return time, carrier speed penalty, HUD scale, beacons, markers, sound, and per-map base positions.
- Host hotkeys place bases by hand per map (F8 / F9) or reset them to automatic (F10).
0.1.0
Internal prototype. Loaded and passed its Harmony checks in-game but never reached a playable match; see the 1.0.0 notes above.