CONTROLLER · SAFE MODE ACTIVATION

Activate Safe Mode Once Without Losing the Final Controller Intent

CURATED PATH

These links are maintained for a concrete prerequisite, failure mode, debugging step, or next lesson instead of generic topic similarity.

Controller

Recommended next steps

Separate Controller risk from Safe Mode stateRelated stateRate-limit defense alertsAlertingAdd Room-event contextRuntime evidenceDiagnosticsContinue diagnosis
Verification statusChinese source article: Reviewed in full · Official docs: Checked — activateSafeMode(), Safe Mode duration, one-room-per-shard restriction, Controller fields and return codes · Official engine: Reviewed at screeps/engine commit 80977824199a596d174d392fd0cf8c458c21fcbd — a later same-tick activation removes the earlier Controller intent

VERIFICATION

Evidence and test status

Chinese source article
Reviewed in full
Official docs
Checked — activateSafeMode(), Safe Mode duration, one-room-per-shard restriction, Controller fields and return codes
Official engine
Reviewed at screeps/engine commit 80977824199a596d174d392fd0cf8c458c21fcbd — a later same-tick activation removes the earlier Controller intent
Identity boundary
The workflow stores the exact request, room and Controller ID and permits one final activateSafeMode() call per tick
Evidence boundary
No Room event is claimed; next-tick Controller state is primary evidence and charge generation must be excluded from the verification window
JavaScript syntax
Passed
Offline decision review
Passed — request identity, Controller preflight, downgrade threshold, deterministic selection, one-final-call guard and next-tick result states
Screeps Console test
Pending
Live same-tick overwrite, activation, charge consumption and next-tick Controller test
Pending
Last verified
August 4, 2026

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.
  • OK means 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

StatusWhat it establishesNext action
accepted-pendingThe final coordinator call returned OKVerify the exact Controller next tick
verifiedSafe Mode is active and one charge was consumed on the exact ControllerClose the operation record
activation-observed-charge-confoundedActivation is visible but the charge delta is not exclusiveReview generateSafeMode or other state changes
overwritten-or-conflictedThe approved Controller did not activate while another owned room is activeFind direct or later same-tick callers
not-observedThe accepted intent did not produce the expected next-tick statePreserve evidence and inspect the integration contract
late-observationThe exact verification window was missedReport current state without claiming exact attribution

Interpret activateSafeMode() return codes

CodeMeaning for this callWhat to inspect
OKThis call placed the current Safe Mode intentLater same-tick callers and next-tick Controller state
ERR_NOT_OWNERThe exact Controller is not ownedController ID, room identity and ownership
ERR_BUSYAnother owned room on the shard already has active Safe ModeCurrent owned Controller Safe Mode fields
ERR_NOT_ENOUGH_RESOURCESNo activation charge is availablesafeModeAvailable
ERR_TIREDCooldown, upgrade block or downgrade threshold prevents activationsafeModeCooldown, 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.

Official sources

SOURCE AND SCOPE

Review the source, evidence, or next system

This English guide keeps the technical scope and verification boundaries of its Chinese source. The verification record shows which checks were completed and which runtime observations are still pending.