StructureController.activateSafeMode() is rare, limited and irreversible for the current activation charge. The difficult bug is not only an accidental retry on a later tick. Independent modules can call it for different rooms during the same tick, receive OK, and still leave only the final Controller intent scheduled.
A reliable workflow therefore needs one global submission point, exact request and Controller identity, a disable-before-call rule, and next-tick verification that does not confuse an accepted command with an observed activation.
The failure mode: two OK results, one surviving intent
This code looks cautious because it targets two explicit Controllers, but it is not safe:
const first = Game.rooms.W1N1.controller.activateSafeMode();
const second = Game.rooms.W2N2.controller.activateSafeMode();
console.log(JSON.stringify({ first, second }));
In the official runtime, activateSafeMode() keeps a per-tick reference to the last Controller used. When another Controller is called later in the same tick, the runtime removes the earlier activation intent before setting the new one. Both calls can return OK because current Controller state has not settled yet, but only the last intent survives.
This is different from ERR_BUSY. ERR_BUSY reports a Safe Mode that is already active in another owned room on the shard. It does not protect two accepted calls made during the same script tick.
What the runtime actually guarantees
The current official implementation establishes these boundaries:
- The Controller must be owned and have at least one available activation.
- Cooldown, upgrade blocking and a low downgrade timer can return
ERR_TIRED. - An already active Safe Mode in any owned room on the shard returns
ERR_BUSY. - A later same-tick activation removes the earlier activation intent.
OKmeans the current call placed an intent; it does not prove that a later caller will not replace it.- When the surviving intent is processed, the exact Controller loses one activation and receives Safe Mode state.
- The engine does not create a Room event that uniquely proves this action.
The practical conclusion is strict: every module that may request Safe Mode must submit through the same coordinator. A direct call elsewhere can still replace the coordinator's chosen intent.
Bind approval to one exact Controller
A room name alone is not enough for a high-impact request. Save a unique request ID, the reviewed room and the exact Controller object ID:
Memory.safeModeRequests ??= {};
Memory.safeModeRequests['W1N1-siege-20260804'] = {
requestId: 'W1N1-siege-20260804',
enabled: true,
confirmed: true,
roomName: 'W1N1',
controllerId: '0123456789abcdef01234567',
priority: 100,
reason: 'spawn-and-storage-under-attack',
requestedAt: Game.time
};
The request fields are project controls, not official API arguments. The exact Controller ID prevents a stale room-level request from silently acting on a different object after ownership or room state changes. The reason is an audit note, not an automated threat classifier.
Do not let a broad condition such as “one hostile is visible” create this record. Safe Mode is documented as a defense tactic of last resort. Threat classification, diplomacy, Tower behavior, defender spawning and fortification health belong upstream.
Inspect current Controller state
Resolve the current object every tick instead of storing a live reference:
function inspectSafeModeRequest(request) {
const room = typeof request?.roomName === 'string'
? Game.rooms[request.roomName]
: null;
const controller = typeof request?.controllerId === 'string'
? Game.getObjectById(request.controllerId)
: null;
return {
roomVisible: Boolean(room),
controllerFound: Boolean(controller),
roomControllerId: room?.controller?.id ?? null,
controllerId: controller?.id ?? null,
controllerRoomName: controller?.pos.roomName ?? null,
owned: controller?.my === true,
level: controller?.level ?? null,
safeMode: controller?.safeMode ?? 0,
safeModeAvailable: controller?.safeModeAvailable ?? null,
safeModeCooldown: controller?.safeModeCooldown ?? 0,
upgradeBlocked: controller?.upgradeBlocked ?? 0,
ticksToDowngrade: controller?.ticksToDowngrade ?? null
};
}
This function is read-only. It distinguishes a missing room, a missing object and a room whose current Controller no longer matches the approved ID.
Evaluate without submitting an intent
The runtime's downgrade restriction is easy to miss. A Controller can have an activation available, no cooldown and no upgrade block, yet still return ERR_TIRED when ticksToDowngrade is below the official Safe Mode threshold.
function evaluateSafeModeRequest(request, state) {
if (!request || request.enabled !== true) {
return { ready: false, reason: 'disabled' };
}
if (request.confirmed !== true) {
return { ready: false, reason: 'confirmation-missing' };
}
if (
typeof request.requestId !== 'string'
|| request.requestId.length === 0
|| typeof request.roomName !== 'string'
|| typeof request.controllerId !== 'string'
|| !Number.isInteger(request.priority)
|| !Number.isInteger(request.requestedAt)
) {
return { ready: false, reason: 'request-invalid' };
}
if (!state.roomVisible || !state.controllerFound) {
return { ready: false, reason: 'controller-unavailable' };
}
if (
state.roomControllerId !== request.controllerId
|| state.controllerId !== request.controllerId
|| state.controllerRoomName !== request.roomName
) {
return { ready: false, reason: 'controller-identity-mismatch' };
}
if (!state.owned || !Number.isInteger(state.level) || state.level <= 0) {
return { ready: false, reason: 'controller-not-owned' };
}
if (state.safeMode > 0) {
return { ready: false, reason: 'already-active' };
}
if (
!Number.isInteger(state.safeModeAvailable)
|| state.safeModeAvailable <= 0
) {
return { ready: false, reason: 'no-activation' };
}
if (state.safeModeCooldown > 0) {
return { ready: false, reason: 'activation-cooldown' };
}
if (state.upgradeBlocked > 0) {
return { ready: false, reason: 'upgrade-blocked' };
}
const minimumDowngradeTicks =
CONTROLLER_DOWNGRADE[state.level] / 2
- CONTROLLER_DOWNGRADE_SAFEMODE_THRESHOLD;
if (
!Number.isFinite(state.ticksToDowngrade)
|| state.ticksToDowngrade < minimumDowngradeTicks
) {
return { ready: false, reason: 'downgrade-threshold' };
}
return { ready: true, reason: 'ready' };
}
This preflight mirrors visible restrictions, but the return code remains authoritative. Another room may already be in Safe Mode, and another module may alter state before the final call.
Use one per-tick dispatcher
Producers should enqueue requests. Only the main loop should finalize them after all defense modules have run:
let coordinatorTick = -1;
let candidates = [];
let finalized = false;
function resetSafeModeCoordinator() {
if (coordinatorTick === Game.time) return;
coordinatorTick = Game.time;
candidates = [];
finalized = false;
}
function enqueueSafeModeRequest(requestId) {
resetSafeModeCoordinator();
if (finalized) {
return { accepted: false, reason: 'coordinator-finalized' };
}
const request = Memory.safeModeRequests?.[requestId];
if (!request || request.enabled !== true) {
return { accepted: false, reason: 'request-disabled' };
}
if (request.requestId !== requestId) {
return { accepted: false, reason: 'request-identity-mismatch' };
}
const state = inspectSafeModeRequest(request);
const plan = evaluateSafeModeRequest(request, state);
if (!plan.ready) {
return { accepted: false, reason: plan.reason };
}
if (candidates.some(item => item.requestId === requestId)) {
return { accepted: true, reason: 'already-enqueued' };
}
candidates.push({
requestId,
priority: request.priority,
requestedAt: request.requestedAt
});
return { accepted: true, reason: 'enqueued' };
}
function chooseSafeModeCandidate() {
const requestIds = candidates.map(item => item.requestId);
if (new Set(requestIds).size !== requestIds.length) {
return { valid: false, reason: 'duplicate-request-id' };
}
const candidate = [...candidates]
.sort((left, right) =>
right.priority - left.priority
|| left.requestedAt - right.requestedAt
|| left.requestId.localeCompare(right.requestId)
)[0] ?? null;
return { valid: true, candidate };
}
The priority is explicit project policy. Each candidate is preflighted before selection, duplicate request IDs are rejected, and stable request ID ordering makes equal valid inputs deterministic. The coordinator does not decide whether a room deserves Safe Mode; it only ensures that reviewed candidates compete in one place.
Each producer imports the same module and enqueues instead of calling the Controller:
const safeMode = require('safe-mode-coordinator');
function runDefenseEscalation(room) {
const requestId = room.memory.safeModeRequestId;
if (typeof requestId !== 'string') return;
safeMode.enqueueSafeModeRequest(requestId);
}
Disable first and save accepted identity
The finalizer executes at most once for the tick:
function finalizeSafeModeRequests() {
resetSafeModeCoordinator();
if (finalized) {
return { status: 'already-finalized' };
}
finalized = true;
if (Memory.safeModePending) {
return { status: 'pending-operation-exists' };
}
const selection = chooseSafeModeCandidate();
if (!selection.valid) {
return { status: selection.reason };
}
if (!selection.candidate) {
return { status: 'no-candidate' };
}
return submitSafeModeRequest(selection.candidate.requestId);
}
The submission function resolves the exact object again, evaluates current state, disables before the API call and creates pending evidence only after OK:
function submitSafeModeRequest(requestId) {
const request = Memory.safeModeRequests?.[requestId];
const state = inspectSafeModeRequest(request);
const plan = evaluateSafeModeRequest(request, state);
if (!request) {
return { status: 'request-missing', requestId };
}
request.checkedAt = Game.time;
request.status = plan.reason;
if (!plan.ready) {
request.enabled = false;
return { status: plan.reason, requestId, state };
}
const controller = Game.getObjectById(request.controllerId);
request.enabled = false;
request.status = 'submitting';
const result = controller.activateSafeMode();
request.result = result;
request.resultAt = Game.time;
if (result !== OK) {
request.status = 'rejected-review-required';
return {
status: request.status,
requestId,
controllerId: controller.id,
result
};
}
request.status = 'accepted-pending';
Memory.safeModePending = {
requestId,
roomName: request.roomName,
controllerId: controller.id,
submittedAt: Game.time,
before: {
safeMode: controller.safeMode ?? 0,
safeModeAvailable: controller.safeModeAvailable,
safeModeCooldown: controller.safeModeCooldown ?? 0
}
};
return {
status: 'accepted-pending',
requestId,
controllerId: controller.id,
result
};
}
Do not re-enable automatically after a rejection. A failed high-impact request needs a new review. Do not report accepted-pending as an activated Safe Mode. A pending operation blocks a second submission until verification stores a terminal record.
Call the finalizer once, after every producer:
module.exports.loop = function () {
const verification = verifySafeModePending();
if (![
'no-pending-operation',
'waiting-for-next-tick'
].includes(verification.status)) {
console.log(JSON.stringify({
type: 'safe-mode-verification',
tick: Game.time,
...verification
}));
}
runAllRoomDefensePlanning();
const outcome = finalizeSafeModeRequests();
if (!['no-candidate', 'already-finalized'].includes(outcome.status)) {
console.log(JSON.stringify({
type: 'safe-mode-submission',
tick: Game.time,
...outcome
}));
}
};
Verify the exact Controller next tick
activateSafeMode() does not emit a unique Room event. The primary evidence is the exact saved Controller on the next tick: Safe Mode is active and one activation has been consumed.
function finishSafeModePending(status, details = {}) {
const pending = Memory.safeModePending;
if (!pending) return { status: 'no-pending-operation' };
const terminal = {
...pending,
...details,
status,
resolvedAt: Game.time
};
Memory.safeModeHistory ??= [];
Memory.safeModeHistory.push(terminal);
if (Memory.safeModeHistory.length > 20) {
Memory.safeModeHistory.shift();
}
const request = Memory.safeModeRequests?.[pending.requestId];
if (request) {
request.status = status;
request.resolvedAt = Game.time;
}
delete Memory.safeModePending;
return terminal;
}
function verifySafeModePending() {
const pending = Memory.safeModePending;
if (!pending) return { status: 'no-pending-operation' };
const expectedTick = pending.submittedAt + 1;
if (Game.time < expectedTick) {
return { status: 'waiting-for-next-tick' };
}
const room = Game.rooms[pending.roomName];
const controller = Game.getObjectById(pending.controllerId);
if (!room || !controller) {
return finishSafeModePending('controller-unavailable', {
roomVisible: Boolean(room),
controllerFound: Boolean(controller)
});
}
if (room.controller?.id !== pending.controllerId) {
return finishSafeModePending(
'controller-identity-mismatch',
{ currentControllerId: room.controller?.id ?? null }
);
}
const safeModeTicks = controller.safeMode ?? 0;
const expectedAvailable =
pending.before.safeModeAvailable - 1;
const activationObserved = safeModeTicks > 0;
const chargeObserved =
controller.safeModeAvailable === expectedAvailable;
const observed = {
activationObserved,
chargeObserved,
safeModeTicks,
safeModeAvailable: controller.safeModeAvailable,
safeModeCooldown: controller.safeModeCooldown ?? 0
};
if (Game.time !== expectedTick) {
return finishSafeModePending('late-observation', observed);
}
if (activationObserved && chargeObserved) {
return finishSafeModePending('verified', observed);
}
if (activationObserved && !chargeObserved) {
return finishSafeModePending(
'activation-observed-charge-confounded',
observed
);
}
const otherActiveRooms = Object.values(Game.rooms)
.filter(visibleRoom =>
visibleRoom.controller?.my === true
&& (visibleRoom.controller.safeMode ?? 0) > 0
)
.map(visibleRoom => visibleRoom.name)
.sort();
return finishSafeModePending(
otherActiveRooms.length > 0
? 'overwritten-or-conflicted'
: 'not-observed',
{ ...observed, otherActiveRooms }
);
}
The exact charge comparison assumes that no Creep calls generateSafeMode() on this Controller during the submission and verification window. If charge generation is allowed concurrently, an active Safe Mode can still be observed, but the activation-count delta is confounded and must not be reported as exact proof.
A late observation can describe current state, but it is weaker evidence for one specific accepted call. Every terminal outcome is copied to a bounded Memory.safeModeHistory record before pending state is cleared, so later submissions cannot erase unavailable, mismatched, confounded, overwritten or unobserved evidence.
Keep failure and ambiguity visible
| Status | What it establishes | Next action |
|---|---|---|
accepted-pending | The final coordinator call returned OK | Verify the exact Controller next tick |
verified | Safe Mode is active and one charge was consumed on the exact Controller | Close the operation record |
activation-observed-charge-confounded | Activation is visible but the charge delta is not exclusive | Review generateSafeMode or other state changes |
overwritten-or-conflicted | The approved Controller did not activate while another owned room is active | Find direct or later same-tick callers |
not-observed | The accepted intent did not produce the expected next-tick state | Preserve evidence and inspect the integration contract |
late-observation | The exact verification window was missed | Report current state without claiming exact attribution |
Interpret activateSafeMode() return codes
| Code | Meaning for this call | What to inspect |
|---|---|---|
OK | This call placed the current Safe Mode intent | Later same-tick callers and next-tick Controller state |
ERR_NOT_OWNER | The exact Controller is not owned | Controller ID, room identity and ownership |
ERR_BUSY | Another owned room on the shard already has active Safe Mode | Current owned Controller Safe Mode fields |
ERR_NOT_ENOUGH_RESOURCES | No activation charge is available | safeModeAvailable |
ERR_TIRED | Cooldown, upgrade block or downgrade threshold prevents activation | safeModeCooldown, upgradeBlocked and ticksToDowngrade |
The same-tick replacement problem is not represented by a separate error code. That is why one shared final dispatcher is required.
Integration contract for a real codebase
- Only the coordinator module may call
activateSafeMode(). - Every producer enqueues a reviewed request with a non-empty unique request ID and never submits directly.
- The main loop calls the finalizer once after all defense planning.
- Each request binds a unique request ID, room name and Controller ID.
- The request is disabled before the API call.
- Pending state is created only after the real return code is
OK, and it blocks replacement until verification finishes. - The verifier runs before new requests are finalized on the next tick.
generateSafeMode()is excluded from the exact charge-verification window or labeled as a confound.- A rejected, missed or ambiguous operation is persisted in bounded history for review and never auto-retried.
- Tests and logs distinguish accepted intent from observed activation.
This contract prevents accidental competition inside code that follows it. It cannot stop a Console command or another module that bypasses the coordinator. Code search for direct activateSafeMode() calls is part of deployment review.
Scope and evidence still pending
This guide covers one final Safe Mode activation per tick across owned rooms. It does not classify threats, select a room strategically, operate Towers, spawn defenders, repair Ramparts, generate activation charges or coordinate different shards. Continue with Controller downgrade recovery for emergency upgrading and Tower attack identity for active defense.
The coordinator, decision branches and verifier can be reviewed offline, but real Screeps Console execution and a live same-tick overwrite test remain Pending. No production combat outcome, charge consumption or player result is claimed without those observations.