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()containsEVENT_UPGRADE_CONTROLLERfor 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
| Result | Meaning in this workflow | Next check |
|---|---|---|
OK | One upgrade intent was accepted | Match the next-tick event |
ERR_NOT_OWNER | The Creep or Controller ownership is wrong | Resolve current objects again |
ERR_BUSY | The Creep is still spawning | Wait; do not treat it as a range failure |
ERR_NOT_ENOUGH_RESOURCES | The Creep has no usable Energy | Repair the Energy supply path |
ERR_INVALID_TARGET | The target is invalid or Controller upgrading is blocked | Inspect type, ownership, and upgradeBlocked |
ERR_NOT_IN_RANGE | The Creep is beyond range 3 | Record movement separately |
ERR_ACCESS_DENIED | You 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_BODYPART | Required active body capability is missing | Inspect 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.