Use this guide when
Use this workflow when several Towers may attack in one tick and you need to prove which Tower fired at which target. A lower hit count is not enough evidence: hostile healing, TOUGH damage reduction, other Towers, Creeps, and target movement can all change the final state. The durable proof is a reviewed operation record followed by matching prior-tick EVENT_ATTACK entries for the exact Tower and target IDs.
Bind one volley to current IDs
function validateTowerAttackRequest(request) {
if (!request || request.enabled !== true) {
return { valid: false, reason: 'disabled' };
}
if (
typeof request.requestId !== 'string'
|| request.requestId.length === 0
|| typeof request.roomName !== 'string'
) {
return {
valid: false,
reason: 'request-invalid'
};
}
return { valid: true, reason: 'valid' };
}
function getOwnedActiveTower(id) {
const tower = Game.getObjectById(id);
return tower
&& tower.structureType === STRUCTURE_TOWER
&& tower.my === true
&& tower.isActive() === true
? tower
: null;
}
Store object IDs and plain values, not live objects. A Tower dispatcher should own the final action for each Tower during the tick.
Apply diplomacy before threat scoring
function getAttackableHostiles(room, allowedUsers) {
const allowed = new Set(
Array.isArray(allowedUsers)
? allowedUsers
: []
);
return room.find(FIND_HOSTILE_CREEPS, {
filter: creep =>
typeof creep.owner?.username === 'string'
&& !allowed.has(creep.owner.username)
});
}
function getTowerThreatScore(creep) {
return (
creep.getActiveBodyparts(ATTACK) * 5
+ creep.getActiveBodyparts(RANGED_ATTACK) * 5
+ creep.getActiveBodyparts(HEAL) * 4
+ creep.getActiveBodyparts(CLAIM) * 3
+ creep.getActiveBodyparts(WORK) * 2
);
}
The allowed-user list and weights are project policies. They must be reviewed independently from the API's definition of a non-owned Creep.
Estimate range and active Power effects
function getTowerRangeAmount(basePower, range) {
const clamped = Math.max(
TOWER_OPTIMAL_RANGE,
Math.min(TOWER_FALLOFF_RANGE, range)
);
const fraction = (
clamped - TOWER_OPTIMAL_RANGE
) / (
TOWER_FALLOFF_RANGE
- TOWER_OPTIMAL_RANGE
);
return Math.floor(
basePower * (1 - TOWER_FALLOFF * fraction)
);
}
function getTowerEffectModifier(tower) {
return (tower.effects || []).reduce(
(modifier, effect) => {
if (
effect.ticksRemaining <= 0
|| (
effect.effect !== PWR_OPERATE_TOWER
&& effect.effect !== PWR_DISRUPT_TOWER
)
) {
return modifier;
}
const values =
POWER_INFO[effect.effect]?.effect;
const factor = Array.isArray(values)
? values[effect.level - 1]
: null;
return Number.isFinite(factor)
? modifier * factor
: modifier;
},
1
);
}
function estimateTowerAttack(tower, target) {
const range = tower.pos.getRangeTo(target);
const rangeAmount = getTowerRangeAmount(
TOWER_POWER_ATTACK,
range
);
return {
range,
rangeAmount,
estimatedRawDamage: Math.floor(
rangeAmount * getTowerEffectModifier(tower)
)
};
}
This is an allocation estimate, not settlement evidence. The event log reports the actual damage event; target boosts and simultaneous actions can make net hits differ from the estimate.
Build one deterministic volley plan
function buildTowerAttackPlan(request) {
const validation =
validateTowerAttackRequest(request);
if (!validation.valid) {
return {
ready: false,
reason: validation.reason
};
}
const room = Game.rooms[request.roomName];
if (!room) {
return {
ready: false,
reason: 'room-not-visible'
};
}
const towers = room.find(FIND_MY_STRUCTURES, {
filter: structure =>
structure.structureType === STRUCTURE_TOWER
&& structure.isActive() === true
&& structure.store.getUsedCapacity(
RESOURCE_ENERGY
) >= TOWER_ENERGY_COST
});
const hostiles = getAttackableHostiles(
room,
request.allowedUsers
);
if (towers.length === 0 || hostiles.length === 0) {
return {
ready: false,
reason: towers.length === 0
? 'no-available-tower'
: 'no-attack-target'
};
}
const target = [...hostiles].sort(
(left, right) =>
getTowerThreatScore(right)
- getTowerThreatScore(left)
|| Math.min(...towers.map(tower =>
tower.pos.getRangeTo(left)
))
- Math.min(...towers.map(tower =>
tower.pos.getRangeTo(right)
))
|| left.id.localeCompare(right.id)
)[0];
return {
ready: true,
reason: 'ready',
room,
target,
towers: [...towers].sort(
(left, right) =>
left.id.localeCompare(right.id)
)
};
}
The target is refreshed from current visible objects. The final ID tie-breaker is deterministic but has no combat meaning.
Submit one intent per Tower and save the contract
function submitTowerAttack(request) {
const plan = buildTowerAttackPlan(request);
request.lastCheckedAt = Game.time;
request.lastStatus = plan.reason;
if (!plan.ready) {
return {
status: 'not-submitted',
reason: plan.reason
};
}
request.enabled = false;
request.submittedAt = Game.time;
request.target = {
id: plan.target.id,
name: plan.target.name,
owner: plan.target.owner?.username ?? null,
hitsBefore: plan.target.hits
};
request.intents = plan.towers.map(tower => {
const estimate = estimateTowerAttack(
tower,
plan.target
);
const energyBefore =
tower.store.getUsedCapacity(
RESOURCE_ENERGY
);
const result = tower.attack(plan.target);
return {
towerId: tower.id,
targetId: plan.target.id,
energyBefore,
...estimate,
result
};
});
request.status = request.intents.some(
intent => intent.result === OK
)
? 'accepted-pending-events'
: 'failed-review-required';
return {
status: request.status,
targetId: plan.target.id,
intents: request.intents
};
}
Disable before the calls so an old approval cannot repeat. Preserve every numeric return code; only accepted Tower IDs are eligible for event verification.
Match exact prior-tick attack events
function verifyTowerAttack(request) {
if (
!request
|| request.status !== 'accepted-pending-events'
) {
return {
status: 'submission-not-accepted'
};
}
if (Game.time <= request.submittedAt) {
return { status: 'pending-next-tick' };
}
if (Game.time !== request.submittedAt + 1) {
return { status: 'event-window-missed' };
}
const room = Game.rooms[request.roomName];
if (!room) {
return { status: 'room-not-visible' };
}
const accepted = request.intents.filter(
intent => intent.result === OK
);
const events = room.getEventLog().filter(
event =>
event.event === EVENT_ATTACK
&& event.data?.attackType
=== EVENT_ATTACK_TYPE_RANGED
);
const matches = accepted.map(intent => {
const event = events.find(item =>
item.objectId === intent.towerId
&& item.data?.targetId === intent.targetId
);
return {
towerId: intent.towerId,
targetId: intent.targetId,
found: Boolean(event),
damage: event?.data?.damage ?? null
};
});
const matchedCount = matches.filter(
item => item.found
).length;
return {
status: matchedCount === accepted.length
? 'verified-tower-volley'
: matchedCount === 0
? 'tower-events-missing'
: 'partial-event-match',
submittedAt: request.submittedAt,
verifiedAt: Game.time,
matches
};
}
Room.getEventLog() exposes events from the previous tick only. Verify at exactly submittedAt + 1; later checks cannot recover the missed event window.
Keep acceptance, events, and net hits separate
| Status | Meaning |
|---|---|
not-submitted | The room, Tower Energy, diplomacy filter, or target selection failed. |
accepted-pending-events | At least one attack() call returned OK; previous-tick events are not available yet. |
tower-events-missing | No accepted Tower ID produced the expected ranged attack event. |
partial-event-match | Only part of the accepted Tower set matched the target. |
verified-tower-volley | Every accepted Tower has a matching prior-tick attack event for the exact target ID. |
Preserve documented return codes
Documented results are OK, ERR_NOT_OWNER, ERR_NOT_ENOUGH_ENERGY, ERR_INVALID_TARGET, and ERR_RCL_NOT_ENOUGH. Tower range changes damage; it does not add an ERR_NOT_IN_RANGE result.
Keep target policy and event proof distinct
This page owns one multi-Tower attack volley and its event identity. Diplomacy expiry, overkill allocation, hostile healing prediction, Rampart tactics, Safe Mode, and defender Creeps require separate policies. Use the Room event-log guide for a broader event consumer.