CONTROLLER · DOWNGRADE RECOVERY

Recover a Downgrading Controller Without Hiding Failed Upgrade Ticks

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

Restore the Controller upgrade loopRecovery actionDebug a fixed Upgrader that stopped workingConcrete failureRate-limit downgrade-risk alertsAlertingDiagnosticsLive diagnosis
Verification statusExisting English route: Preserved — no URL, slug, Canonical, or Chinese mapping change · Official documentation: Checked August 18, 2026 — StructureController.ticksToDowngrade, upgradeBlocked, Creep.upgradeController() range and current return codes including restricted-shard ERR_ACCESS_DENIED, and Room.getEventLog() · Static code review: Passed — emergency entry/exit hysteresis, project-defined reaction runway, upgradeBlocked preflight, exact action result capture, and next-tick Controller event identity remain separate boundaries

VERIFICATION

Evidence and test status

Existing English route
Preserved — no URL, slug, Canonical, or Chinese mapping change
Official documentation
Checked August 18, 2026 — StructureController.ticksToDowngrade, upgradeBlocked, Creep.upgradeController() range and current return codes including restricted-shard ERR_ACCESS_DENIED, and Room.getEventLog()
Static code review
Passed — emergency entry/exit hysteresis, project-defined reaction runway, upgradeBlocked preflight, exact action result capture, and next-tick Controller event identity remain separate boundaries
Project-policy boundary
Recovery thresholds and lead-time components are examples to measure for one colony; they are not official Screeps safe values
Evidence level
Official-documentation review and static code review only; no real-shard recovery sequence is claimed
Console test pending
Not run in this editorial pass
Screeps Console test
Pending — no real-shard Controller recovery transcript was collected for this revision
Live multi-tick verification pending
No live emergency-entry, accepted upgrade, next-tick event, threshold exit, ownership failure, blocked-upgrade failure, or restricted-shard access trace was collected for this revision
Genuine in-game screenshot
Pending — no new first-party Controller recovery screenshot was captured for this revision
Last editorial review
August 18, 2026

Use this guide when

Your owned Controller is approaching downgrade and you need code that can answer three separate questions: should the room enter recovery mode, did one specific Upgrader call reach the API, and did that exact Creep produce an upgrade event on the next tick?

This is not a general Upgrader role tutorial. It is a recovery controller for rooms that already have an Energy path and at least one candidate Creep.

What recovery must prove

upgradeController() returning OK means the intent was accepted in the current tick. It does not prove that ticksToDowngrade already increased in the same script execution, and a later net timer change cannot identify one Creep when several Upgraders are active.

Keep three evidence layers separate:

  • Decision: the live Controller timer crossed a project-defined threshold.
  • Submission: one Creep, Controller, tick, and return code were recorded.
  • Settlement: the next tick's Room.getEventLog() contains EVENT_UPGRADE_CONTROLLER for that Creep.

Enter and leave recovery deliberately

Use a lower entry threshold and a higher exit threshold. The values below are examples. They must be chosen from the room's travel time, Energy availability, replacement time, and acceptable risk.

function decideControllerRecovery(input) {
  if (
    input.owned !== true
    || !Number.isInteger(input.ticksToDowngrade)
    || input.ticksToDowngrade < 0
  ) {
    return { active: false, reason: 'controller-unavailable' };
  }

  if (
    !Number.isInteger(input.enterAt)
    || !Number.isInteger(input.leaveAt)
    || input.enterAt <= 0
    || input.leaveAt <= input.enterAt
  ) {
    return { active: false, reason: 'invalid-thresholds' };
  }

  if (input.wasActive === true) {
    return input.ticksToDowngrade >= input.leaveAt
      ? { active: false, reason: 'recovered' }
      : { active: true, reason: 'recovery-continues' };
  }

  return input.ticksToDowngrade <= input.enterAt
    ? { active: true, reason: 'recovery-entered' }
    : { active: false, reason: 'normal' };
}

Do not calculate recovery from CONTROLLER_DOWNGRADE[level] alone. That constant is the level-specific maximum timer context; controller.ticksToDowngrade is the live state used by this decision.

Choose an emergency threshold from reaction runway

ticksToDowngrade is an API state value; the tick at which your bot enters emergency mode is your own operations policy. A fixed number such as 5,000 is not automatically safe for every room. Estimate how many ticks your recovery path actually needs, then add an explicit safety margin.

function describeRecoveryRunway(input) {
  const components = [
    input.replacementLeadTicks,
    input.travelTicks,
    input.energyHandoffTicks,
    input.safetyMarginTicks
  ];

  if (
    !Number.isInteger(input.ticksToDowngrade)
    || input.ticksToDowngrade < 0
    || components.some(value =>
      !Number.isInteger(value) || value < 0
    )
  ) {
    return {
      valid: false,
      reason: 'invalid-runway-input'
    };
  }

  const runwayTicks = components.reduce(
    (sum, value) => sum + value,
    0
  );

  return {
    valid: true,
    runwayTicks,
    ticksToDowngrade: input.ticksToDowngrade,
    shouldEscalate:
      input.ticksToDowngrade <= runwayTicks
  };
}

This is a conservative project-planning example, not a Screeps formula. Only sum delays that are genuinely sequential in your own recovery path. If replacement spawning, travel, and Energy delivery overlap, measure that workflow instead of pretending the full sum is observed runtime evidence.

Reject blocked or unready attempts

A recovery loop should not issue an API call merely because the timer is low. Check the Controller and the selected Creep first.

function selectRecoveryUpgrader(room, controller) {
  const candidates = room.find(FIND_MY_CREEPS, {
    filter: creep =>
      creep.spawning !== true
      && creep.getActiveBodyparts(WORK) > 0
      && creep.getActiveBodyparts(CARRY) > 0
      && creep.store.getUsedCapacity(RESOURCE_ENERGY) > 0
  });

  return candidates.sort((left, right) =>
    left.pos.getRangeTo(controller)
      - right.pos.getRangeTo(controller)
    || right.getActiveBodyparts(WORK)
      - left.getActiveBodyparts(WORK)
    || right.store.getUsedCapacity(RESOURCE_ENERGY)
      - left.store.getUsedCapacity(RESOURCE_ENERGY)
    || left.name.localeCompare(right.name)
  )[0] || null;
}

function inspectRecoveryPreflight(room) {
  const controller = room?.controller || null;

  if (!controller || controller.my !== true) {
    return { ready: false, status: 'owned-controller-not-found' };
  }

  if (
    Number.isInteger(controller.upgradeBlocked)
    && controller.upgradeBlocked > 0
  ) {
    return {
      ready: false,
      status: 'controller-upgrade-blocked',
      upgradeBlocked: controller.upgradeBlocked
    };
  }

  const creep = selectRecoveryUpgrader(room, controller);
  if (!creep) {
    return { ready: false, status: 'ready-upgrader-not-found' };
  }

  return { ready: true, status: 'ready', controller, creep };
}

upgradeBlocked is a live API boundary. Waiting for it to reach zero is different from fixing an empty Upgrader, a damaged body, or an unavailable Creep.

Submit one identifiable upgrade

Store object IDs rather than live objects. A pending record belongs to one accepted call and one expected event window.

function submitRecoveryUpgrade(room, state) {
  const preflight = inspectRecoveryPreflight(room);
  if (!preflight.ready) {
    return preflight;
  }

  const { controller, creep } = preflight;
  const before = {
    ticksToDowngrade: controller.ticksToDowngrade,
    progress: Number.isFinite(controller.progress)
      ? controller.progress
      : null,
    creepEnergy: creep.store.getUsedCapacity(RESOURCE_ENERGY)
  };
  const result = creep.upgradeController(controller);

  if (result === ERR_NOT_IN_RANGE) {
    const moveResult = creep.moveTo(controller, {
      range: 3,
      reusePath: 5
    });

    return {
      status: moveResult === OK
        ? 'movement-submitted'
        : moveResult === ERR_TIRED
          ? 'movement-deferred-fatigue'
          : 'movement-rejected',
      result,
      moveResult,
      creepId: creep.id,
      controllerId: controller.id
    };
  }

  if (result !== OK) {
    return {
      status: 'upgrade-rejected',
      result,
      creepId: creep.id,
      controllerId: controller.id
    };
  }

  const pending = {
    requestId: [
      room.name,
      Game.time,
      creep.id,
      controller.id
    ].join(':'),
    submittedAt: Game.time,
    roomName: room.name,
    creepId: creep.id,
    controllerId: controller.id,
    before
  };

  state.pending = pending;

  return {
    status: 'upgrade-accepted',
    result,
    pending
  };
}

A movement result is not an upgrade result. When the Creep is outside range 3, this function records movement and waits for a later tick before trying the work action again.

Verify the prior tick by event identity

Read the pending record exactly one tick later. The room event log identifies the acting object through objectId. For this event, the room itself supplies the Controller context; when a targetId is present, the verifier also checks it.

function verifyRecoveryUpgrade(room, state) {
  const pending = state.pending;
  if (!pending) {
    return { status: 'nothing-pending' };
  }

  if (Game.time <= pending.submittedAt) {
    return { status: 'waiting-for-next-tick' };
  }

  if (Game.time !== pending.submittedAt + 1) {
    delete state.pending;
    return {
      status: 'verification-window-missed',
      requestId: pending.requestId
    };
  }

  const events = room.getEventLog();
  const matches = events.filter(event =>
    event.event === EVENT_UPGRADE_CONTROLLER
    && event.objectId === pending.creepId
    && (
      typeof event.data?.targetId !== 'string'
      || event.data.targetId === pending.controllerId
    )
  );

  const controller = Game.getObjectById(pending.controllerId);
  const netState = controller
    ? {
        ticksToDowngradeNow: controller.ticksToDowngrade,
        progressNow: Number.isFinite(controller.progress)
          ? controller.progress
          : null
      }
    : null;

  delete state.pending;

  if (matches.length === 0) {
    return {
      status: 'accepted-event-not-found',
      requestId: pending.requestId,
      netState
    };
  }

  if (matches.length > 1) {
    return {
      status: 'ambiguous-upgrade-events',
      requestId: pending.requestId,
      matchCount: matches.length,
      netState
    };
  }

  return {
    status: 'upgrade-event-verified',
    requestId: pending.requestId,
    eventData: matches[0].data || null,
    before: pending.before,
    netState
  };
}

The event is the primary evidence for that actor. ticksToDowngradeNow and progressNow are net room state: other Upgraders or Controller effects can change them during the same tick.

Complete recovery loop

function runControllerRecovery(roomName) {
  Memory.controllerRecovery ??= {};
  const state = Memory.controllerRecovery[roomName] ??= {
    enabled: true,
    enterAt: 5000,
    leaveAt: 10000,
    active: false,
    pending: null
  };

  if (state.enabled !== true) {
    return { status: 'disabled' };
  }

  if (state.lastRunAt === Game.time) {
    return { status: 'already-run-this-tick' };
  }
  state.lastRunAt = Game.time;

  const room = Game.rooms[roomName];
  const controller = room?.controller || null;
  if (!room || !controller || controller.my !== true) {
    return { status: 'owned-room-not-visible' };
  }

  const verification = verifyRecoveryUpgrade(room, state);
  if (
    verification.status !== 'nothing-pending'
    && verification.status !== 'waiting-for-next-tick'
  ) {
    state.lastVerification = verification;
    state.lastVerifiedAt = Game.time;
  }

  const decision = decideControllerRecovery({
    owned: controller.my,
    ticksToDowngrade: controller.ticksToDowngrade,
    enterAt: state.enterAt,
    leaveAt: state.leaveAt,
    wasActive: state.active
  });

  state.active = decision.active;
  state.lastDecision = decision.reason;
  state.lastTicksToDowngrade = controller.ticksToDowngrade;

  if (!decision.active) {
    return { status: decision.reason, verification };
  }

  if (state.pending) {
    return { status: 'upgrade-already-pending', verification };
  }

  const submission = submitRecoveryUpgrade(room, state);
  state.lastSubmission = submission.status;
  state.lastSubmissionAt = Game.time;

  return { status: submission.status, submission, verification };
}

module.exports.loop = function () {
  const outcome = runControllerRecovery('W1N1');

  if (
    outcome.status.includes('rejected')
    || outcome.status.includes('not-found')
    || outcome.status.includes('missed')
    || Game.time % 100 === 0
  ) {
    console.log(JSON.stringify({
      type: 'controller-recovery',
      gameTick: Game.time,
      outcome
    }));
  }
};

The verifier runs before a new submission. That ordering prevents the next action from overwriting the evidence record for the previous one.

Return-code boundaries

ResultMeaning in this workflowNext check
OKOne upgrade intent was acceptedMatch the next-tick event
ERR_NOT_OWNERThe Creep or Controller ownership is wrongResolve current objects again
ERR_BUSYThe Creep is still spawningWait; do not treat it as a range failure
ERR_NOT_ENOUGH_RESOURCESThe Creep has no usable EnergyRepair the Energy supply path
ERR_INVALID_TARGETThe target is invalid or Controller upgrading is blockedInspect type, ownership, and upgradeBlocked
ERR_NOT_IN_RANGEThe Creep is beyond range 3Record movement separately
ERR_ACCESS_DENIEDYou do not have access to the restricted shard where the action is being attempted.Check restricted-shard access; do not diagnose this code as Controller ownership or reservation failure.
ERR_NO_BODYPARTRequired active body capability is missingInspect active WORK and CARRY

Production adaptation notes

  • Replace the example thresholds with room-specific policy. Do not copy them as universal safety values.
  • Give recovery a clear priority against building, fortification, and market spending; this article does not define the full room scheduler.
  • A Controller Link can solve Energy delivery, but it does not prove that an Upgrader is alive, in range, or carrying Energy.
  • Keep pending records bounded. A missed one-tick event window should fail closed instead of being guessed from later net state.
  • At RCL 8, Controller upgrading has additional limits. Do not infer one Creep's contribution from progress or timer changes alone.

Choose another guide when

Use the beginner Upgrader loop when the room does not yet have a working harvest-and-upgrade cycle. Use the coordinated Link guide when the Upgrader is ready but the Controller-side Link is not being supplied. Use the Safe Mode guide for a confirmed defensive activation request; Safe Mode and downgrade recovery solve different risks.

Official documentation

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.