Skip to content

Option to Skip Camera shake while player immunity active #6439

Open

Description

@boltactiongaming

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.

Image

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

  1. boltactiongaming commented on Sep 19, 2026

    @boltactiongaming
    Author

    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.

  2. changed the title [-]Race condition or fundamental failure in Hurtplayer(e,v)[/-] [+]GGMax Bug - Race condition or fundamental failure in Hurtplayer(e,v)[/+] on Sep 23, 2026
  3. added
    bugSomething isn't working
    MaxIssues related to GameGuru Max.
    on Sep 23, 2026
  4. PaulFox12 commented on Sep 29, 2026

    @PaulFox12

    Looking at the engine code and the script, this might be engine-driven player spawn immunity rather than a race. physics_player_takedamage silently 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!

    1. Start the level, stand still for a full 6 seconds or longer, then walk into the "hurt once only" zone. Does it apply damage every time?
    2. Same again, but walk straight in as soon as you spawn. Does it fail?
    3. Try adding 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

  5. boltactiongaming commented on Sep 29, 2026

    @boltactiongaming
    Author

    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?

  6. PaulFox12 commented on Sep 29, 2026

    @PaulFox12

    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.

  7. added
    enhancementNew feature or request
    and removed
    bugSomething isn't working
    on Oct 12, 2026
  8. changed the title [-]GGMax Bug - Race condition or fundamental failure in Hurtplayer(e,v)[/-] [+]Option to Skip Camera shake while player immunity active[/+] on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

MaxIssues related to GameGuru Max.enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions