Heartbeat: is the Bastrel client still running?
Bastrel’s client script is small and decides nothing. To know it still runs on a player’s machine, the server sends it a random number (a nonce) every 30 seconds and the client sends it back. A player whose client stops answering while he keeps moving is a player who stopped Bastrel’s client script, which is what many cheat menus do first. This page covers BAS-HB-001, the second signal BAS-HB-002 (a resource equipped with the SDK reports that Bastrel’s client stopped) and the guard that stops a server or network problem from becoming a wave of kicks.
Status: journal only, for now
Section titled “Status: journal only, for now”BAS-HB-001 and BAS-HB-002 ship as unverified. For BAS-HB-001 the server compares the position of the player’s ped between two nonces (GetEntityCoords), and that native was read on a real player’s ped on 2026-10-20 (it follows the player), but the kick delay has not been measured with a client that stops answering. For BAS-HB-002 the message of the SDK and the client event it relies on were never captured in game. Until a test in game shows it, each code is held in observe whatever your configuration says:
- it appears in the log and in the detections table, with
would_haveempty; - no alert, no Discord message, no score, no kick, no ban;
- nothing is cancelled.
It is lifted in a later release, after a capture on a real server. The messages and counters below already work, so you can read the journal to see how your players behave.
| Code | What it sees | Severity | How certain |
|---|---|---|---|
BAS-HB-001 |
Two replies in a row missing while the player moves | high | deterministic |
BAS-HB-002 |
A resource with the SDK reports that Bastrel’s client stopped, and the client did not answer a probe | medium | deterministic |
BAS-CV-108 |
Most replies missing at once: the server or the network is at fault, the kicks are held back | high | finding about the server, never about a player |
How it works
Section titled “How it works”- Every
heartbeat.intervalMs(30 s) a tracked player gets a nonce. His client answers withinheartbeat.replyMs(9 s) with the nonce and a number: the server sends with every nonce the last number it accepted (the base) and the client answers base + 1, so a restart of the client script or of the server needs nothing else to be back in step. - A reply is accepted once, for the nonce that player was sent, with the counter that follows the last accepted one (a jump is refused) and on time. A late, replayed or foreign reply is only counted (
stale): a player at 300 ms of ping is never reported. - On a first missed reply the server sends a second nonce at once, without waiting for the next interval. Two missed replies in a row are
BAS-HB-001, if the player moves during the silence: his ped is further thanheartbeat.movedMeters(2 m) at the verdict from where it was when the unanswered nonce went out. Someone who ran before the silence and stands still during it is not judged; a position the server cannot read (or the null vector of a missing entity) is never a move. A player who is a passenger of a vehicle driven by another player is never judged either (his position is the vehicle’s; the same when the driver cannot be read; an EMPTY driver seat does not exempt him). A player who stands still (AFK, menu) is never judged; this is a known limit: a cheat that stops the heartbeat of a player who does not move goes unseen. - From the moment the client stops, the kick (once the code is lifted) comes within 56.4 seconds (68.2 seconds when the verdict was held back once, see below) with the default sampler (
sampler.intervalMs1500,tickMs500). The bound isheartbeat.intervalMs + 2 x replyMs + 3 x W, plusreplyMs + Wwhen held back, with W = 2 xsampler.intervalMs-sampler.tickMs(a player’s slice of the sampler can move back from one period to the next), each wait being allowedheartbeat.tickOverrunMs(60 ms) more per sampler tick, since a real tick lasts longer thantickMs(the 60 ms comes from a measure on a dev server with one player: a real tick of 500 ms lasted 510.5 ms on average and 550.7 ms at most; measure it again on your server). Bastrel writes aheartbeat.tick_overrunline (at most one perheartbeat.logWindowMs) when a sampler tick lasts longer thantickMsplus that allowance: the bound then no longer holds for your load, raiseheartbeat.tickOverrunMs. The bound holds up tosampler.maxPerTickx ticks per period tracked players (192 by default); past it the sampler period stretches andheartbeat.capacityis logged once per episode. Withsampler.intervalMs2000 it is 59.76 s and 72.68 s, past the 70 seconds Bastrel aims for, and Bastrel says so at startup. - Nothing is judged during
heartbeat.graceMs(120 s) after the connection, after the character is loaded (QBX) or after a restart of Bastrel, nor for a player the framework says is not loaded (no nonce is even sent to him). Without a load signal from the framework adapter, a player who is not loaded stays in the guard’s count of eligible players. - A player is reported once per silence: while he stays mute after a report he is not reported again and his missing replies no longer count in the guard below; his next valid reply re-arms the report.
- The journal gets a
heartbeat.missedline for a missing reply (relance,replyMs) and aheartbeat.staleline for a refused reply (reason:no_pending,nonce,sequenceorlate), at most one of each per player andheartbeat.logWindowMs(60 s).
When the server or the network fails: BAS-CV-108
Section titled “When the server or the network fails: BAS-CV-108”A hitch of the server, a network problem or a restart of Bastrel must never kick half of your players. Every nonce, answered or not, leaves an outcome when its deadline is reached. When a verdict is due, the server looks at the replies due in the last heartbeat.replyMs:
- if more than
heartbeat.massMissRatio(half) are missing, with at leastheartbeat.massMinPlayers(6) players tracked and at leastheartbeat.massMinExpired(3) replies due, the verdicts are held back forheartbeat.massPauseMs(60 s) and dropped: they are not served late. One findingBAS-CV-108is written for the episode; - with too few replies to tell, the verdict waits
replyMsonce and is measured again over a window twice as long; - at most
heartbeat.massMaxPauses(3) pauses in a row: a real mass cheat cannot switch the control off forever. The count starts again afterheartbeat.massCooldownMs(5 min) without a pause; - if the server itself was blocked (a gap between two checks above
heartbeat.stallMs, 5 s), the deadlines of that gap are unknown: nobody is judged on them; - a player the framework says is not loaded is not counted at all. A wave of connections that loses its pings, players in their grace included, is a mass outage like any other and can open a pause: holding the verdicts back is the safe outcome, and the budget of pauses starts again after
heartbeat.massCooldownMs.
Only BAS-HB-001 is held back; every other check keeps running.
The second signal: the SDK reports a stop (BAS-HB-002)
Section titled “The second signal: the SDK reports a stop (BAS-HB-002)”A stopped client script cannot say so itself. A resource of yours that loads the SDK (see sdk.md) listens to the client event onClientResourceStop; when the resource that stopped is bastrel, it sends the server the single fact “bastrel stopped” (bastrel:sdk:stopped, with the name of your resource). Nothing else is sent, and the client decides nothing.
Two honest causes look exactly the same from the client: you restart Bastrel, and the player disconnects (all his resources stop). So the server does not trust the message alone:
- a signal is ignored during
heartbeat.restartGraceMs(30 s) after Bastrel started (the clients see the stop of Bastrel before the server has finished restarting), for a player whose character the framework says is not loaded (the probe would be lost), and during a mass-outage pause (see below). The server does not ask Bastrel’s own state: the handler runs inside Bastrel, so the answer could only be “started”; - otherwise the signal is held for
heartbeat.stopDelayMs(5 s) and the server immediately sends the player a probe: a ping like the heartbeat’s, with its own number. A running Bastrel client answers it within a few hundred milliseconds. The probe is not part of the heartbeat: its answer does not move the counter, and a missing answer is not a missed heartbeat. Only a running client knows the random number of the probe, so a reply that carries it clears the signal whatever its counter (a wrong counter is only logged,probe_seq); a reply that comes after the signal was judged or cleared is refused asprobe_late; - at the end of the delay the player is reported (
BAS-HB-002, medium, deterministic) only if he is still connected, his character is not known to be unloaded, no mass-outage pause is running and the probe is still unanswered. A player who leaves during the delay is not reported (aleftline is written); - if the server itself was blocked during the delay, the signal is dropped: the answer to the probe may have been held back;
- several resources of the same client send the signal at once: one signal is held per player and per window (the larger of
heartbeat.stopReportMs, 60 s, andheartbeat.pongRatePerMs), whatever its outcome, so that a client with twenty equipped resources costs one probe and never goes over the rate of the reply. A forged signal followed by a real stop inside that window is not judged; the heartbeat above still catches the stop.
A player can send the message by hand, since it is a network event. The only effect is that he may be reported; nobody else can be.
Every outcome of a signal but the report itself leaves one heartbeat.stop_signal line (a report is the BAS-HB-002 line) in the journal, with a result: held, answered, probe_seq, restart_window, not_loaded, paused, duplicate, queue_full, left or stalled (and the resource that sent it), at most one per player, result and heartbeat.logWindowMs. This is how you see in game what happened to a signal. If the server cannot register the event, heartbeat.stop_disabled is logged and the signal is off; the heartbeat keeps working.
Known limits. A forged signal that the live client answers opens the window above, so a real stop inside it is not judged by BAS-HB-002 (the heartbeat still catches it); closing this gap is a condition for lifting unverified. With heartbeat.intervalMs at its minimum of 10 s the reply rate (4 per 10 s) is full: raise heartbeat.pongRateMax (Bastrel warns at startup). Bastrel also warns at startup when the number of resources that load the SDK reaches heartbeat.stopRateMax: past it, an honest client sending one signal per resource would be rate limited (BAS-NE-002), and when stopDelayMs is below stallMs: a server block between the two values is not seen as a stall, and a held signal would be judged without the answer of its probe (a false BAS-HB-002). The count of resources is best effort: it only sees the resources already started when Bastrel starts.
Settings (config/modules.lua, block heartbeat)
Section titled “Settings (config/modules.lua, block heartbeat)”| Setting | Default | Meaning |
|---|---|---|
heartbeat.enabled |
true |
false: no nonce, no check. |
heartbeat.intervalMs |
30000 |
One nonce per player and interval (10000 to 120000). |
heartbeat.replyMs |
9000 |
Time to answer (2000 to 20000, below intervalMs). |
heartbeat.graceMs |
120000 |
No judgement this long after the join, the character load or a restart (30000 to 600000). |
heartbeat.movedMeters |
2.0 |
A player “moves” when, at the verdict, he is further than this from where he was when the unanswered nonce went out (0.5 to 50). |
heartbeat.massMissRatio |
0.5 |
Share of missing replies above which the verdicts are held back (0.1 to 1). |
heartbeat.massMinPlayers |
6 |
Players needed for the guard to apply (2 to 1000). |
heartbeat.massMinExpired |
3 |
Replies due needed to trust the ratio (2 to 20). |
heartbeat.massPauseMs |
60000 |
How long the verdicts are held back (5000 to 600000). |
heartbeat.massMaxPauses |
3 |
Pauses in a row at most (1 to 10). |
heartbeat.massCooldownMs |
300000 |
Without a pause for this long, the count of pauses starts again (60000 to 3600000). |
heartbeat.stallMs |
5000 |
Gap between two checks above which the server is considered blocked (4000 to 60000, above twice sampler.intervalMs). |
heartbeat.pongRateMax |
4 |
Replies accepted per pongRatePerMs (1 to 20); at least 2 x (pongRatePerMs // intervalMs + 1), or both take their default. |
heartbeat.pongRatePerMs |
10000 |
Window of that rate (1000 to 60000). |
heartbeat.ringMax |
8192 |
Most outcomes the guard keeps in memory (256 to 65536). |
heartbeat.logWindowMs |
60000 |
Shortest time between two heartbeat.missed (or two heartbeat.stale) lines of a player (1000 to 3600000). |
heartbeat.tickOverrunMs |
60 |
Overrun of a sampler tick beyond sampler.tickMs counted in the worst kick delay; Bastrel warns at startup when the bound reaches 70 s (0 to 500). |
heartbeat.restartGraceMs |
30000 |
A stop signal is ignored this long after Bastrel started (1000 to 300000). |
heartbeat.stopDelayMs |
5000 |
How long a stop signal is held while the probe is out (1000 to 30000). |
heartbeat.stopReportMs |
60000 |
One stop signal per player and window, the window being the larger of this and pongRatePerMs (1000 to 300000). |
heartbeat.stopRateMax |
64 |
Stop signals accepted per player and stopRatePerMs (1 to 500); keep it above the number of resources that load the SDK. |
heartbeat.stopRatePerMs |
60000 |
Window of that rate (1000 to 600000). |
heartbeat.stopQueueMax |
256 |
Most stop signals held at once (16 to 4096). |
An invalid value takes its default and a warning is printed at startup; Bastrel never stops for it. Nothing from this block is sent to the client.
What the client holds
Section titled “What the client holds”client/heartbeat.lua answers the nonce and nothing else: no threshold, no period, no code, no secret. The nonce and the base are not secret (the client receives them); the nonce only ties a reply to its request. The two heartbeat events are bastrel:hb:ping and bastrel:hb:pong (the probe uses the same pair); the SDK adds bastrel:sdk:stopped, sent by the SDK file in your resources (see sdk.md), plain, without the numbering of the other events: the bastrel: prefix is reserved to Bastrel, and the reply goes through the same size, rate and schema controls as any network event, so an honest client never produces a BAS-NE-* line.
A premium menu that answers the heartbeat itself, or a player who does not move, is not caught here. The heartbeat is there to catch the low-end executors and to give evidence; the protection rests on the other checks.