Repository navigation
Option to Skip Camera shake while player immunity active #6439
Description
Issue: Many scripts rely on Hurtplayer(e,v) in Lua to apply damage. However I've noticed in testing that this is extremely inconsistent on a number of scripts. I cannot trace whether this is a fundamental issue with hurtplayer itself in the engine, or it's an issue from a race condition in the code. It appears that the best way to validate this issue is to do a 'single instance' of damage to a player with something like the spike trap or hurt zone. It will not reliably apply damage.
Reproduction of the issue: Create a new blank level. Put in a hurt zone, with 'hurt only once' set. Step in the zone. Nine out of Ten times, it will not apply damage at all.
Verification testing performed:* Tested nearly a hundred times with various traps, hurtzones, etc. Multi-application works 100% of the time, but only because it continually applies damage until it starts to work. The INITIAL application of damage still has to be retried many times before it succeeds.
Single use is extremely unreliable. Had AI rewrite and massage hurt.lua to continually attempt until success could be verified. This is a band-aid over a possibly much wider issue affecting all scripts. Interestingly, checking VSYNC on made the issue NOTABLY worse - with average ranges exceeding 20 (on a 100ms retry window, that's 2 full seconds to push damage out!).
Either the issue is a problem with the way the engine is taking inputs, or the scripts themselves. Something is preventing first damage attempts, likely engine or timing based.
This is my 'test' code for hurt.lua:
`-- Hurt v7
-- DESCRIPTION: Hurts the player over time or once only when they enter the zone.
-- DESCRIPTION: Can also update a specified User Global by the same amount.
-- DESCRIPTION: Set the Hurt [AMOUNT=1(1-20)] and the [TIMING=1(1,10)] and the [ZONEHEIGHT=100(1,500)]
-- DESCRIPTION: [SpawnAtStart!=1] if unchecked use a switch or other trigger to spawn this zone
-- DESCRIPTION: [HurtOnceOnly!=0] to hurt once only then zone is destroyed.
-- DESCRIPTION: [@@USER_GLOBAL_AFFECTED$=""(0=globallist)] eg; MyGlobal
-- DESCRIPTION: for when entering the zone
module_activationcontrol = require "scriptbank\module_activationcontrol"
local hurt = {}
local amount = {}
local timing = {}
local zoneheight = {}
local SpawnAtStart = {}
local HurtOnceOnly = {}
local user_global_affected = {}
local doonce = {}
local currentvalue = {}
local triggered = {}
local wait = {}
local hurt_now = {}
local status = {}
local one_shot_state = {}
local one_shot_health = {}
local one_shot_last_attempt = {}
-- Give MAX a frame to process each request before trying again. This avoids
-- stacking many calls in the same frame if HurtPlayer() is asynchronous.
local ONE_SHOT_RETRY_MS = 100
-- Shared diagnostic counters for every instance using this script.
-- HurtPlayer() does not return whether damage was accepted, so actual damage
-- is detected by watching g_PlayerHealth across subsequent engine frames.
local hurt_debug = {
calls = 0,
requested_damage = 0,
damage_events = 0,
actual_damage = 0,
last_health = nil
}
local function hurt_debug_update()
if hurt_debug.last_health == nil then
hurt_debug.last_health = g_PlayerHealth
elseif g_PlayerHealth < hurt_debug.last_health then
hurt_debug.damage_events = hurt_debug.damage_events + 1
hurt_debug.actual_damage = hurt_debug.actual_damage + (hurt_debug.last_health - g_PlayerHealth)
hurt_debug.last_health = g_PlayerHealth
elseif g_PlayerHealth > hurt_debug.last_health then
-- Healing is not damage, but it becomes the new comparison baseline.
hurt_debug.last_health = g_PlayerHealth
end
local untracked_calls = hurt_debug.calls - hurt_debug.damage_events
if untracked_calls < 0 then untracked_calls = 0 end
PromptDuration(
"Hurt calls: " .. hurt_debug.calls ..
" | Health-loss events: " .. hurt_debug.damage_events ..
" | Calls without observed loss: " .. untracked_calls ..
" | Requested damage: " .. hurt_debug.requested_damage ..
" | Actual health lost: " .. hurt_debug.actual_damage ..
" | Health: " .. g_PlayerHealth
,3000)
end
local function hurt_debug_call(amount)
hurt_debug.calls = hurt_debug.calls + 1
hurt_debug.requested_damage = hurt_debug.requested_damage + amount
HurtPlayer(-1,amount)
end
function hurt_properties(e, amount, timing, zoneheight, SpawnAtStart, HurtOnceOnly, user_global_affected)
hurt[e]['amount'] = amount
hurt[e]['timing'] = timing
hurt[e]['zoneheight'] = zoneheight or 100
hurt[e]['SpawnAtStart'] = SpawnAtStart
hurt[e]['HurtOnceOnly'] = HurtOnceOnly
hurt[e]['user_global_affected'] = user_global_affected
end
function hurt_init(e)
hurt[e] = {}
hurt[e]['amount'] = 1
hurt[e]['timing'] = 1
hurt[e]['zoneheight'] = 100
hurt[e]['SpawnAtStart'] = 1
hurt[e]['HurtOnceOnly'] = 0
hurt[e]['user_global_affected'] = ""
StartTimer(e)
wait[e] = math.huge
hurt_now[e] = 0
doonce[e] = 0
one_shot_state[e] = 0
one_shot_health[e] = 0
one_shot_last_attempt[e] = 0
status[e] = "init"
end
function hurt_main(e)
hurt_debug_update()
if status[e] == "init" then
if hurt[e]['SpawnAtStart'] == 1 then SetActivated(e,1) end
if hurt[e]['SpawnAtStart'] == 0 then SetActivated(e,0) end
hurt[e]['timing'] = hurt[e]['timing']*1000
status[e] = "endinit"
end
if g_Entity[e]['activated'] == 1 then
-- A once-only zone is not allowed to finish until an actual reduction in
-- g_PlayerHealth has been observed on a later engine frame.
if hurt[e]['HurtOnceOnly'] == 1 and one_shot_state[e] == 1 then
if g_PlayerHealth < one_shot_health[e] then
one_shot_state[e] = 2
if hurt_now[e] == 0 then
PlaySoundIfSilent(e,0)
hurt_now[e] = Timer()
wait[e] = hurt_now[e] + hurt[e]['timing']/3
end
if hurt[e]['user_global_affected'] ~= "" then
if _G["g_UserGlobal['"..hurt[e]['user_global_affected'].."']"] ~= nil then
currentvalue[e] = _G["g_UserGlobal['"..hurt[e]['user_global_affected'].."']"]
end
if currentvalue[e] > 0 then
_G["g_UserGlobal['"..hurt[e]['user_global_affected'].."']"] = currentvalue[e] - hurt[e]['amount']
end
end
else
-- If the player was healed while the request was pending, use the
-- new health as the baseline for detecting the eventual hit.
if g_PlayerHealth > one_shot_health[e] then
one_shot_health[e] = g_PlayerHealth
end
if g_PlayerHealth > 0 and Timer() >= one_shot_last_attempt[e] + ONE_SHOT_RETRY_MS then
hurt_debug_call(hurt[e]['amount'])
one_shot_last_attempt[e] = Timer()
end
end
end
if g_Entity[e]['plrinzone']==1 and g_PlayerHealth > 0 and g_PlayerPosY > g_Entity[e]['y'] and g_PlayerPosY < g_Entity[e]['y'] + hurt[e]['zoneheight'] then
if hurt[e]['HurtOnceOnly'] == 0 then
if GetTimer(e) > hurt[e]['timing'] then
hurt_debug_call(hurt[e]['amount'])
StartTimer(e)
if hurt_now[e] == 0 then
PlaySoundIfSilent(e,0)
hurt_now[e] = Timer()
end
if hurt[e]['user_global_affected'] ~= "" then
if _G["g_UserGlobal['"..hurt[e]['user_global_affected'].."']"] ~= nil then currentvalue[e] = _G["g_UserGlobal['"..hurt[e]['user_global_affected'].."']"] end
if currentvalue[e] > 0 then _G["g_UserGlobal['"..hurt[e]['user_global_affected'].."']"] = currentvalue[e] - hurt[e]['amount'] end
end
end
end
if hurt[e]['HurtOnceOnly'] == 1 and one_shot_state[e] == 0 then
if GetTimer(e) > hurt[e]['timing'] then
one_shot_health[e] = g_PlayerHealth
hurt_debug_call(hurt[e]['amount'])
one_shot_last_attempt[e] = Timer()
one_shot_state[e] = 1
end
end
else
if Timer() > hurt_now[e] + hurt[e]['timing'] then
hurt_now[e] = 0
end
end
if hurt[e]['HurtOnceOnly'] == 1 and one_shot_state[e] == 2 and g_Time > wait[e] then
Destroy(e)
end
if g_Entity[e]['plrinzone']==0 and g_Time > wait[e] then
Destroy(e)
end
end
end
`
Again, this is duplicable across multiple scripts. I've verified it with:
rockfall
hurt
spiketrap
arrow
I believe I've also encountered this with NPC_control as well, but I cannot confirm at this time.
Activity
boltactiongaming commentedon Sep 19, 2026
Did some AI analysis using Devin and CGPT.
Devin doesn't think it's an internal cooldown; the code supports this.
The most likely thing I can find is a race condition.
Specifically - a frame-order race—Lua submits damage before the player controller resets its damage state, then the controller discards or overwrites it later that frame—but the practical defect is identical: HurtPlayer() provides no reliable transaction semantics.
[-]Race condition or fundamental failure in Hurtplayer(e,v)[/-][+]GGMax Bug - Race condition or fundamental failure in Hurtplayer(e,v)[/+]PaulFox12 commentedon Sep 29, 2026
Looking at the engine code and the script, this might be engine-driven player spawn immunity rather than a race.
physics_player_takedamagesilently ignores all damage for about 5 seconds after the level starts or the player respawns (and about 1 second after a teleport) but the camera still shakes, so it looks like a hit landed (M-Physics.cpp#L2398).Can yuo try these!
GetGamePlayerStateImmunity()to the prompt next to your counters. is the correlation between that number and whether or not you tske damage?I could be very wrong but worth a test! I imagine some of your tests have already been past the 5 second mark but doesn't hurt to try if you get 10 mins!
cheers
boltactiongaming commentedon Sep 29, 2026
I performed several tests and it appears to match up 100%.
Good call on this. The immunity counter is definitely active while it doesn't allow the hit.
So this definitely seems to be the source of the issue. Perhaps a better answer is reorder level loading + player start?
PaulFox12 commentedon Sep 29, 2026
Glad to hear! if this IS the cause, re-ordering the load would probably not be enough: the immunity looks like a 5 second timer that starts when the game loop starts, and because the player can move from the first frame, a trap within 5 seconds of the start would still lose the hit. but more importantly, respawns get the same 5s and teleports get 1s, which a load reorder wouldn't touch.
A solution might be making the hurt-once traps read
GetGamePlayerStateImmunity()and wait for it to settle to 0 before firing and destroying themselves. That covers level start, respawn and teleport. On the engine side, skipping the camera shake while immune would stop it looking like a hit landed, and Lee could shorten the level-start immunity.[-]GGMax Bug - Race condition or fundamental failure in Hurtplayer(e,v)[/-][+]Option to Skip Camera shake while player immunity active[/+]