Quick answer
Send a notification when a condition enters risk, optionally repeat it after a tick-based interval, and re-arm it after recovery. Store state per alert key, normalize every message to 1000 characters or fewer, and submit at most 20 queued notifications per tick. groupInterval is measured in minutes, not ticks.
Official notification limits
| Rule | Official boundary | Application requirement |
|---|---|---|
| Message length | Maximum 1000 characters | Truncate before submission |
| Scheduled per tick | Maximum 20 | Use one priority queue |
groupInterval | Minutes | Do not reuse a tick count |
| Default grouping | 0 | Scheduled immediately |
| Simulation Room | Unavailable | Live delivery requires another environment |
The API reference does not document a delivery receipt that proves an email or profile notification arrived.
Use three independent rate controls
| Layer | Unit | Purpose |
|---|---|---|
| State transition | Business state | Notify immediately when normal becomes risky |
| Repeat interval | Game ticks | Allow a long-running risk to remind again |
groupInterval | Real minutes | Let the notification system group messages |
Grouping does not prevent your code from calling the API every tick. The state machine must control when a call is allowed.
Evaluate alert state with a pure function
function evaluateAlert(input) {
const previous = input.previousState || {
active: false,
lastSubmittedTick: null
};
const isRisk = input.isRisk === true;
if (!isRisk) {
return {
shouldQueue: false,
reason: previous.active ? 'recovered' : 'normal',
nextState: {
active: false,
lastSubmittedTick: previous.lastSubmittedTick
}
};
}
const enteredRisk = previous.active !== true;
const lastTick = Number.isInteger(
previous.lastSubmittedTick
)
? previous.lastSubmittedTick
: null;
const repeatDue =
lastTick !== null
&& Number.isInteger(input.repeatAfterTicks)
&& input.repeatAfterTicks >= 0
&& input.currentTick - lastTick
>= input.repeatAfterTicks;
const shouldQueue = enteredRisk || repeatDue;
return {
shouldQueue,
reason: enteredRisk
? 'entered-risk'
: repeatDue
? 'repeat-due'
: 'risk-active',
nextState: {
active: true,
lastSubmittedTick: shouldQueue
? input.currentTick
: lastTick
}
};
}
This pure function supports offline tests without calling Game.notify().
Enforce the 1000-character limit
function normalizeNotificationMessage(message) {
const text = String(message);
if (text.length <= 1000) {
return text;
}
return text.slice(0, 997) + '...';
}
Do not append arbitrary serialized objects or full stack traces without a size budget.
Keep state per alert key
function getAlertState(key) {
Memory.alertStates ??= {};
return Memory.alertStates[key] || null;
}
function setAlertState(key, state) {
Memory.alertStates ??= {};
Memory.alertStates[key] = state;
}
A key such as controller-risk:W1N1 isolates rooms. Similar keys can isolate Spawns, Terminals, remote operations, or CPU conditions.
Complete Controller risk alert
State impact: this code writes alert state and queues an alert in Memory. It does not submit the external notification until the queue processor runs.
const CONTROLLER_ALERT_THRESHOLD = 5000;
const CONTROLLER_REPEAT_TICKS = 5000;
function evaluateAlert(input) {
const previous = input.previousState || {
active: false,
lastSubmittedTick: null
};
if (input.isRisk !== true) {
return {
shouldQueue: false,
reason: previous.active ? 'recovered' : 'normal',
nextState: {
active: false,
lastSubmittedTick: previous.lastSubmittedTick
}
};
}
const enteredRisk = previous.active !== true;
const lastTick = Number.isInteger(
previous.lastSubmittedTick
)
? previous.lastSubmittedTick
: null;
const repeatDue =
lastTick !== null
&& input.currentTick - lastTick
>= input.repeatAfterTicks;
const shouldQueue = enteredRisk || repeatDue;
return {
shouldQueue,
reason: enteredRisk
? 'entered-risk'
: repeatDue
? 'repeat-due'
: 'risk-active',
nextState: {
active: true,
lastSubmittedTick: shouldQueue
? input.currentTick
: lastTick
}
};
}
function enqueueNotification(alert) {
Memory.notificationQueue ??= [];
Memory.notificationQueue.push(alert);
}
function checkControllerRisk(room) {
const controller = room.controller;
if (
!controller
|| controller.my !== true
|| !Number.isFinite(controller.ticksToDowngrade)
) {
return 'controller-unavailable';
}
const key = 'controller-risk:' + room.name;
const previous = getAlertState(key);
const decision = evaluateAlert({
isRisk:
controller.ticksToDowngrade
< CONTROLLER_ALERT_THRESHOLD,
currentTick: Game.time,
previousState: previous,
repeatAfterTicks: CONTROLLER_REPEAT_TICKS
});
setAlertState(key, decision.nextState);
if (!decision.shouldQueue) {
return decision.reason;
}
enqueueNotification({
key,
createdAt: Game.time,
expiresAt: Game.time + 1000,
priority: 100,
groupMinutes: 60,
message: normalizeNotificationMessage(
[
'[controller-risk]',
'room=' + room.name,
'ticksToDowngrade=' +
controller.ticksToDowngrade,
'threshold=' + CONTROLLER_ALERT_THRESHOLD,
'reason=' + decision.reason,
'gameTick=' + Game.time
].join(' ')
)
});
return 'queued';
}
The thresholds are example policy, not official recommendations.
Build a central priority queue
function normalizeNotificationQueue(queue) {
if (!Array.isArray(queue)) {
return [];
}
const byKey = new Map();
for (const item of queue) {
if (
!item
|| typeof item.key !== 'string'
|| typeof item.message !== 'string'
|| !Number.isFinite(item.priority)
|| !Number.isInteger(item.createdAt)
|| !Number.isInteger(item.expiresAt)
) {
continue;
}
if (item.expiresAt < Game.time) {
continue;
}
const previous = byKey.get(item.key);
if (
!previous
|| item.priority > previous.priority
|| item.createdAt > previous.createdAt
) {
byKey.set(item.key, item);
}
}
return [...byKey.values()].sort(
(a, b) =>
b.priority - a.priority
|| a.createdAt - b.createdAt
|| a.key.localeCompare(b.key)
);
}
Deduplication prevents multiple modules from submitting the same condition in one tick.
Submit at most 20 notifications
State impact: this function calls Game.notify() up to 20 times and keeps remaining valid items in Memory.
function submitNotificationQueue() {
const queue = normalizeNotificationQueue(
Memory.notificationQueue
);
const submitted = queue.slice(0, 20);
const deferred = queue.slice(20);
for (const item of submitted) {
Game.notify(
normalizeNotificationMessage(item.message),
Number.isFinite(item.groupMinutes)
? Math.max(0, item.groupMinutes)
: 0
);
}
Memory.notificationQueue = deferred;
return {
submitted: submitted.length,
deferred: deferred.length
};
}
Call this once after every producer has queued alerts. Submission count does not prove external delivery.
Do not claim delivery success
The reliable local evidence is:
- the condition entered a notification state;
- the queue accepted a normalized item;
- the queue processor selected it within the 20-call limit;
- your code called
Game.notify().
Email, profile, client, or account-delivery behavior requires live verification outside the offline state machine.
Recovery should re-arm the alert
When risk ends, set active to false but retain the previous submission tick for history. A later transition into risk becomes a new entered-risk event and can notify immediately.
const recovered = evaluateAlert({
isRisk: false,
currentTick: 2000,
previousState: {
active: true,
lastSubmittedTick: 1500
},
repeatAfterTicks: 5000
});
Debugging checklist
- Keep tick intervals separate from grouping minutes.
- Normalize every message to 1000 characters or fewer.
- Store state per stable alert key.
- Notify on entry, due repeats, and re-entry after recovery.
- Use one central queue.
- Deduplicate by key.
- Expire stale queue items.
- Sort by explicit priority.
- Submit no more than 20 per tick.
- Do not claim external delivery without a live test.
Scope and next steps
This guide does not implement account-channel monitoring, webhook delivery, retry receipts, cross-shard queues, Segment-backed alert history, or live email verification. Continue with Room.getEventLog() to create alerts from previous-tick combat events.
Frequently asked questions
Can groupInterval replace Memory cooldowns?
No. It groups notifications in minutes; your business state still needs tick-based control.
Should every module call Game.notify directly?
No. A central queue enforces the account-wide per-tick limit.
Should recovery send a notification?
That is a product decision. The example records recovery only so the next risk entry can alert again.
Can the Simulation validate delivery?
No. The official API says notifications are unavailable there.