Skip to content

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.

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_have empty;
  • 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
  • Every heartbeat.intervalMs (30 s) a tracked player gets a nonce. His client answers within heartbeat.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 than heartbeat.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.intervalMs 1500, tickMs 500). The bound is heartbeat.intervalMs + 2 x replyMs + 3 x W, plus replyMs + W when held back, with W = 2 x sampler.intervalMs - sampler.tickMs (a player’s slice of the sampler can move back from one period to the next), each wait being allowed heartbeat.tickOverrunMs (60 ms) more per sampler tick, since a real tick lasts longer than tickMs (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 a heartbeat.tick_overrun line (at most one per heartbeat.logWindowMs) when a sampler tick lasts longer than tickMs plus that allowance: the bound then no longer holds for your load, raise heartbeat.tickOverrunMs. The bound holds up to sampler.maxPerTick x ticks per period tracked players (192 by default); past it the sampler period stretches and heartbeat.capacity is logged once per episode. With sampler.intervalMs 2000 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.missed line for a missing reply (relance, replyMs) and a heartbeat.stale line for a refused reply (reason: no_pending, nonce, sequence or late), at most one of each per player and heartbeat.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 least heartbeat.massMinPlayers (6) players tracked and at least heartbeat.massMinExpired (3) replies due, the verdicts are held back for heartbeat.massPauseMs (60 s) and dropped: they are not served late. One finding BAS-CV-108 is written for the episode;
  • with too few replies to tell, the verdict waits replyMs once 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 after heartbeat.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 as probe_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 (a left line 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, and heartbeat.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.

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.