Quick answer
Keep exactly one module.exports.loop in the main module. Let each role file export a small function such as run(creep), require modules through explicit names, validate their public shape, and read Game, Room, Creep and Structure objects inside the current tick. Module-scope constants and functions are appropriate; module-scope snapshots of current world objects are not.
Keep one exported game loop
const roleHarvester = require('role.harvester');
const roleUpgrader = require('role.upgrader');
module.exports.loop = function () {
for (const creep of Object.values(Game.creeps)) {
if (creep.memory.role === 'harvester') {
roleHarvester.run(creep);
} else if (creep.memory.role === 'upgrader') {
roleUpgrader.run(creep);
}
}
};
Do not export a second loop from a role file and expect both loops to run. Main must call role functions explicitly.
Give each role one contract
function run(creep) {
if (!creep || creep.spawning === true) {
return { status: 'creep-unavailable' };
}
const source = creep.pos.findClosestByPath(
FIND_SOURCES_ACTIVE
);
if (!source) {
return { status: 'source-not-found' };
}
const result = creep.harvest(source);
if (result === ERR_NOT_IN_RANGE) {
return {
status: 'moving-to-source',
result,
moveResult: creep.moveTo(source, {
range: 1,
reusePath: 10
})
};
}
return {
status: result === OK
? 'harvest-scheduled'
: 'harvest-rejected',
result,
sourceId: source.id
};
}
module.exports = { run };
A role receives the current Creep, performs one bounded decision and returns diagnostics. It does not search all Creeps again or own the global loop.
Route Creeps from main
const roles = {
harvester: require('role.harvester'),
upgrader: require('role.upgrader')
};
function runCreepRole(creep) {
const roleName = creep?.memory?.role;
const role = roles[roleName];
if (!role) {
return {
status: 'unknown-role',
roleName: roleName || null
};
}
return role.run(creep);
}
The role map makes supported names explicit. A typo should produce a diagnosis instead of silently selecting another behavior.
Validate module shape
function validateRoleModules(roleMap) {
const errors = [];
for (const [roleName, roleModule] of Object.entries(roleMap)) {
if (!roleModule || typeof roleModule.run !== 'function') {
errors.push({
roleName,
reason: 'missing-run-function'
});
}
}
return {
valid: errors.length === 0,
errors
};
}
This catches export mismatches before the router tries to call an absent function.
Read current state inside the tick
This captures one old collection:
const cachedHarvesters = Object.values(Game.creeps)
.filter(creep => creep.memory.role === 'harvester');
function getHarvesters() {
return cachedHarvesters;
}
Read current objects when the function runs:
function getCurrentHarvesters() {
return Object.values(Game.creeps)
.filter(creep =>
creep.memory.role === 'harvester'
)
.sort((left, right) =>
left.name.localeCompare(right.name)
);
}
The same rule applies to Game.rooms, Game.structures, room.find() results and objects recovered by ID.
Understand module cache and global resets
const ROLE_NAMES = Object.freeze([
'harvester',
'upgrader'
]);
const roleNameSet = new Set(ROLE_NAMES);
function isKnownRole(roleName) {
return roleNameSet.has(roleName);
}
Stable definitions can live at module scope. A global reset reruns initialization, so counters, queues and caches there are disposable unless rebuilt safely.
global.runtimeCache ??= {
initializedAt: Game.time,
roleNames: new Set()
};
This cache is acceleration, not durable state.
Complete modular example
const roles = {
harvester: require('role.harvester'),
upgrader: require('role.upgrader')
};
const validation = validateRoleModules(roles);
module.exports.loop = function () {
if (!validation.valid) {
if (Game.time % 100 === 0) {
console.log(JSON.stringify({
type: 'role-module-validation',
errors: validation.errors
}));
}
return;
}
for (const creepName of Object.keys(Game.creeps).sort()) {
const creep = Game.creeps[creepName];
const outcome = runCreepRole(creep);
if (
outcome.status === 'unknown-role'
|| outcome.status.endsWith('-rejected')
) {
console.log(JSON.stringify({
type: 'creep-role-result',
creepName,
...outcome
}));
}
}
};
Handle unknown roles explicitly
function describeUnknownRole(creep) {
return {
gameTick: Game.time,
creepName: creep?.name || null,
roleName: creep?.memory?.role || null,
status: 'unknown-role'
};
}
Do not rewrite an unknown role unless a separate migration defines the intended mapping.
Diagnose module load failures
- Fix the first syntax error in the required file.
- Check the exact module and file names.
- Check whether the export is an object or function.
- Check for circular dependencies.
- Keep the exported game loop in main.
- Avoid reading unavailable tick objects during module initialization.
Debugging checklist
- Keep one
module.exports.loop. - Give each role one
run(creep)contract. - Validate loaded module shapes.
- Use an explicit role map.
- Report unknown roles.
- Read current game objects inside the tick.
- Keep only stable definitions at module scope.
- Treat global caches as disposable.
- Store persistent state in serializable Memory.
Scope and next steps
This guide does not provide a bundler, process manager, dependency injection framework or complete role AI. Continue with the next reviewed operations guide.
Frequently asked questions
Can one module export several functions?
Yes. Keep the public contract small and explicit, such as { run, plan, validate }.
Should main catch every role exception?
A guarded boundary can keep other Creeps running, but it must preserve the error instead of turning every failure into silent success.
Can module-scope constants reference Screeps constants?
Stable constants are appropriate. Avoid capturing current world objects and query results.