Quick answer
Wrap each owned room, independent subsystem, or similarly recoverable execution unit in its own try...catch boundary. When one unit throws, save a bounded structured record and continue the loop. Keep ordinary Screeps OK and ERR_* results in explicit branches, and keep CPU protection in a separate scheduler.
For optional work, add a small failure window, log interval, cooldown, and automatic retry. For critical Spawn, harvesting, Controller safety, or defense work, prefer next-tick retry instead of a long automatic pause.
Why later rooms stop
A loop does not automatically skip a failed iteration when a called function throws. Without a matching catch, control leaves the current call chain before later rooms run.
module.exports.loop = function () {
for (const room of Object.values(Game.rooms)) {
if (room.controller?.my !== true) {
continue;
}
runRoom(room);
}
};
If runRoom(W1N1) reads anchor.x while anchor is undefined, the later W2N2 and W3N3 calls are not reached in that execution. The next tick starts the main loop again, but unchanged input can reproduce the same failure.
Separate three failure types
| Failure | Example | Correct handling |
|---|---|---|
| JavaScript exception | TypeError, ReferenceError, explicit throw | Catch at a recoverable boundary, record evidence, fix the cause |
| Screeps API return code | ERR_NOT_IN_RANGE, ERR_FULL | Save the returned number and branch explicitly |
| CPU execution boundary | Later work is not reached after the tick budget is exhausted | Measure, prioritize, throttle, and degrade separately |
const result = creep.transfer(
target,
RESOURCE_ENERGY
);
if (result === ERR_NOT_IN_RANGE) {
return {
status: 'moving',
result,
moveResult: creep.moveTo(target)
};
}
return {
status: result === OK
? 'submitted'
: 'failed',
result
};
An API return code does not become an exception merely because the call is inside try...catch.
Why one outer catch is insufficient
module.exports.loop = function () {
try {
runRoom(Game.rooms.W1N1);
runRoom(Game.rooms.W2N2);
runRoom(Game.rooms.W3N3);
} catch (error) {
console.log(error);
}
};
If W1N1 throws, control jumps to the single catch and then leaves the function. The catch prevents an uncaught exception from escaping, but it does not resume at W2N2.
Add the minimum room boundary
function runOwnedRooms(runRoom) {
const rooms = Object.values(Game.rooms)
.filter(room => room.controller?.my === true)
.sort((left, right) =>
left.name.localeCompare(right.name)
);
const outcomes = [];
for (const room of rooms) {
try {
outcomes.push({
roomName: room.name,
ok: true,
value: runRoom(room)
});
} catch (error) {
outcomes.push({
roomName: room.name,
ok: false,
errorName: error instanceof Error
? error.name
: 'NonErrorThrow',
message: error instanceof Error
? error.message
: String(error)
});
}
}
return outcomes;
}
This is the smallest useful blast-radius boundary: one failure becomes one result, while the loop continues. It still needs rate limits and bounded state before production use.
Store bounded failure state
A useful optional-task record needs only enough data to decide whether to log, pause, retry, and report recovery:
function getGuardState(key) {
Memory.runtimeGuard ??= { units: {} };
Memory.runtimeGuard.units ??= {};
Memory.runtimeGuard.units[key] ??= {
errorTicks: [],
consecutiveErrors: 0,
totalErrors: 0,
disabledUntil: null,
lastLogAt: null,
lastSuccessAt: null,
lastError: null
};
return Memory.runtimeGuard.units[key];
}
Do not serialize an entire Room, Creep, or large configuration object. Keep a stable key, tick, short message, bounded stack, counters, and retry time.
Build a reusable runtime guard
function normalizeThrown(thrown) {
if (thrown instanceof Error) {
return {
name: thrown.name || 'Error',
message: thrown.message || String(thrown),
stack: typeof thrown.stack === 'string'
? thrown.stack.split('\n').slice(0, 6).join('\n')
: null
};
}
let message;
try {
message = typeof thrown === 'string'
? thrown
: JSON.stringify(thrown);
} catch {
message = String(thrown);
}
return {
name: 'NonErrorThrow',
message: message ?? String(thrown),
stack: null
};
}
function runGuarded(key, task, options = {}) {
const config = {
windowTicks: 100,
maxErrors: 3,
cooldownTicks: 50,
logIntervalTicks: 20,
breakerEnabled: true,
...options
};
const state = getGuardState(key);
const firstTick = Game.time - config.windowTicks + 1;
state.errorTicks = state.errorTicks.filter(
tick => tick >= firstTick
);
if (
config.breakerEnabled
&& Number.isInteger(state.disabledUntil)
&& Game.time < state.disabledUntil
) {
return {
ok: false,
status: 'cooldown',
retryAt: state.disabledUntil
};
}
if (
Number.isInteger(state.disabledUntil)
&& Game.time >= state.disabledUntil
) {
state.disabledUntil = null;
state.errorTicks = [];
state.consecutiveErrors = 0;
}
try {
const value = task();
const recovered = state.consecutiveErrors > 0;
state.consecutiveErrors = 0;
state.lastSuccessAt = Game.time;
state.lastError = null;
return {
ok: true,
status: recovered ? 'recovered' : 'ok',
value
};
} catch (thrown) {
const error = normalizeThrown(thrown);
state.errorTicks.push(Game.time);
state.consecutiveErrors += 1;
state.totalErrors += 1;
state.lastError = {
tick: Game.time,
...error
};
const breakerTripped =
config.breakerEnabled
&& state.errorTicks.length >= config.maxErrors;
if (breakerTripped) {
state.disabledUntil = Game.time + config.cooldownTicks;
}
const logDue =
!Number.isInteger(state.lastLogAt)
|| Game.time - state.lastLogAt
>= config.logIntervalTicks;
if (logDue) {
console.log(JSON.stringify({
type: 'runtime-guard-error',
tick: Game.time,
key,
breakerTripped,
retryAt: state.disabledUntil,
error
}));
state.lastLogAt = Game.time;
}
return {
ok: false,
status: breakerTripped
? 'disabled'
: 'error',
retryAt: state.disabledUntil,
error
};
}
}
The numeric thresholds are local policy values, not official recommendations. Tune them from real error frequency and room risk.
Separate critical and optional work
module.exports.loop = function () {
const rooms = Object.values(Game.rooms)
.filter(room => room.controller?.my === true);
for (const room of rooms) {
const critical = runGuarded(
'critical:' + room.name,
() => roomManager.runCritical(room),
{
breakerEnabled: false,
logIntervalTicks: 20
}
);
if (!critical.ok) {
continue;
}
runGuarded(
'optional:' + room.name,
() => roomManager.runOptional(room),
{
breakerEnabled: true,
windowTicks: 100,
maxErrors: 3,
cooldownTicks: 50,
logIntervalTicks: 20
}
);
}
};
Critical work should remain small and retryable. Optional visuals, statistics, long-range planning, or market scans can use a cooldown when repeated exceptions would otherwise waste CPU every tick.
Read structured error evidence
| Field | Question it answers |
|---|---|
key | Which room and task boundary failed? |
tick | When did this exact observation occur? |
breakerTripped | Did this failure start a cooldown? |
retryAt | When may the optional task run again? |
error.name | What JavaScript failure class was observed? |
error.message | What immediate condition failed? |
error.stack | Which bounded call chain reached the failure? |
A catch is not a repair. It preserves the rest of the system while evidence guides the real null check, migration, identity fix, visibility guard, or module correction.
Retry after cooldown
Before disabledUntil, the optional task returns cooldown without calling its function. At or after the retry tick, clear the current failure window and attempt the task once. A successful call should record a recovery event; another exception starts a new failure sequence.
function summarizeGuardOutcome(key, outcome) {
if (outcome.status === 'recovered') {
return {
type: 'runtime-guard-recovered',
tick: Game.time,
key
};
}
if (outcome.status === 'cooldown') {
return {
type: 'runtime-guard-cooldown',
tick: Game.time,
key,
retryAt: outcome.retryAt
};
}
return null;
}
Debugging checklist
- Find the first exception, not only the latest repeated line.
- Record its room, task key, tick, type, message, and first project stack frame.
- Confirm whether one outer catch still skips later rooms.
- Check missing Memory fields, destroyed objects, lost visibility, stale IDs, and unrebuilt heap cache.
- Handle
ERR_*results in ordinary branches. - Measure CPU separately when there is no exception stack.
- After the code or input is fixed, observe a recovery and several later healthy ticks.
Scope and limitations
This pattern applies to multiple owned rooms, role dispatchers, optional diagnostics, market scans, remote planning, and other independently recoverable units. It does not recover syntax errors that prevent loading, infinite loops, top-level module initialization failures, CPU termination, shard outages, or a complete Memory schema migration.
Repository syntax and offline control-flow assertions do not prove live Screeps CPU cost, multi-room behavior, global-reset recovery, or external alert delivery. Those remain explicit live-test tasks.
Frequently asked questions
The FAQ below keeps return-code handling, critical-task retry, CPU boundaries, and stack-size decisions separate from exception isolation.