Player Object
Returned by local_player(), get_players(), event callbacks, and modifier.caster.
All methods are colon-style: p:method().
Identity
| Function | Returns | Description |
|---|---|---|
p:hero_name() | string | Display name: "Ivy", "Vindicta", "Viscous", etc. |
p:get_hero_id() | integer | Numeric hero ID (compare with hero_id enum) |
p:get_team() | integer | Team number (typically 2 or 3) |
p:is_local() | boolean | True only for the local player |
p:is_enemy() | boolean | True if on the opposing team |
p:is_valid() | boolean | True if player data is currently readable |
p:entity_ptr() | integer | Raw pawn pointer (advanced use) |
p:steam_id() | integer | SteamID64; 0 for bots, SourceTV, or not-yet-replicated. Works for enemies |
p:get_steam_id() | integer | Alias for p:steam_id() |
State
| Function | Returns | Description |
|---|---|---|
p:is_alive() | boolean | Health > 0 |
p:get_health() | number | Current health |
p:get_max_health() | number | Maximum health |
p:get_health_percentage() | number | Health as a percentage (0 to 100) |
p:is_visible() | boolean | Visible to local player (vischeck) |
p:is_on_screen() | boolean | Within screen bounds |
p:is_scoped() | boolean | Currently scoped or zoomed. Local player only: always false on other handles |
p:is_in_reload() | boolean | Currently reloading. Local player only: always false on other handles |
p:get_reload_progress() | number | Reload progress 0.0 to 1.0. Local player only: always 0.0 on other handles |
p:is_primary_weapon_active() | boolean | Not implemented. Always false. The backing field is never written by the reader |
p:is_in_melee_attack() | boolean | Not implemented. Always false. The backing field is never written by the reader |
p:is_targetable() | boolean | Non-local handles: alive and valid and visible and not untargetable. It folds in the vischeck, so an enemy behind a wall reports false even when the game would let you target them. Always false on the local player |
p:get_active_projectile_speed() | number | Effective gun projectile speed (u/s), includes velocity item multiplier. Returns 0 until the gun resolves. Local player only |
p:get_level() | integer | nil | Hero level. nil, never 0, before the value has been read |
p:get_souls() | integer | Total souls (net worth) |
p:get_unsecured_souls() | integer | Unsecured souls (lost on death) |
An ability has no level of its own. Its upgrade tier is get_ability(slot).points, in the Abilities section below.
p:get_max_health() reads the controller's max health field. p:get_property("m_iMaxHealth") reads the pawn entity's own field. The two differ on the same pawn by design, and which of them matches the health bar has not been confirmed.
Position and Movement
| Function | Returns | Description |
|---|---|---|
p:get_position() | vec3 | World position (feet) |
p:get_head_world() | vec3 | nil | Head position in world space, falling back to position.z + 78 when the head bone is unset. Returns nil when the handle no longer resolves to a snapshot. get_position() and get_velocity() return a zero vector instead, so guard this one before indexing .x |
p:get_velocity() | vec3 | Movement velocity vector |
p:get_view_angles() | vec2 | Pitch and yaw in degrees (no roll) |
p:bone_pos(name_or_index) | vec3 | nil | World position of a skeleton bone. Accepts a name string ("head", "arm_upper_l", …) or a bone.* constant; raw integer indexes work as legacy. An unknown name does not return nil. The resolver falls back to the head bone, then to the player's origin, so p:bone_pos("not_a_bone") hands back the feet position. nil comes back only from the integer form with an out-of-range index, or when even the fallback resolves to (0,0,0). Compare the name against p:bone_names() if you need to know it really resolved |
p:get_bone_position(...) | vec3 | nil | Legacy alias for p:bone_pos, literally the same function. Identical behavior |
p:bone_names() | table | All available bone names for this player's current model. Empty table if bones haven't been probed yet |
p:is_bone_visible(bone) | boolean | On-demand BVH ray trace from the camera to the named/indexed bone. Accepts the same argument as bone_pos. Always a boolean, never nil: false for a stale handle or a bone that resolves to (0,0,0). An unknown bone name is not false. It resolves the same way bone_pos does and traces to the player's feet. The integer form is bounds-checked against the bone count, which is 0 on the local player, so any index returns false there |
p:hitboxes() | table | The model's hitbox capsules in world space: the same shapes the triggerbot tests and the engine traces for damage |
p:get_distance([other]) | number | Metres to the local player, or to other if given. Returns the sentinel 9999 (not a distance) when this handle is stale, when there is no local player, or when other is a stale handle |
No skeleton on the local player
The local player's snapshot carries no bone data. The bone array, the named bones and the hitbox set are all left empty. On local_player():
bone_pos(index)always returnsnilbone_pos("head")and every other name falls through to the origin, i.e. the feet positionbone_names()still lists names, but none of them resolve to a positionis_bone_visible(index)is alwaysfalse; the name form traces to the feethitboxes()returns an empty table
get_head_world() and screen_head() are unaffected. They use their own position.z + 78 fallback, which is why they disagree with bone_pos("head") on your own player.
Hitboxes
p:hitboxes() returns the model's real hitbox capsule set in world space: the same shapes the triggerbot tests and targeting.is_on_target ray-tests. Use it when you want your own hit logic instead of rebuilding a body out of bone positions.
Each entry of the 1-based array has:
| Field | Type | Description |
|---|---|---|
bone | string | Bone this capsule hangs off |
a | vec3 | Capsule segment start, world space, game units |
b | vec3 | Capsule segment end (a == b means the capsule is a sphere) |
radius | number | This capsule's own radius, game units |
group | integer | Engine hitgroup id |
Always a table, never nil. You get an empty one when the handle is stale or the model carries no hitbox set, so #p:hitboxes() is always safe. At most 40 capsules are exposed.
The local player has no hitbox data: local_player():hitboxes() is always empty. Only other players are populated.
local boxes = target:hitboxes()
if #boxes == 0 then
-- no hitbox set for this model (or it's the local player):
-- targeting.is_on_target falls back to its bone-pair sweep
end
for _, h in ipairs(boxes) do
print(h.bone, h.radius, h.group)
endStamina
Local player only
Stamina is not replicated to other clients. These functions return 0 when called on enemy or teammate handles.
| Function | Returns | Description |
|---|---|---|
p:get_stamina() | number | Current stamina (e.g. 3.0) |
p:get_max_stamina() | number | Maximum stamina (e.g. 4.0) |
local me = local_player()
local pct = me:get_stamina() / me:get_max_stamina() * 100
print(string.format("Stamina: %.0f%%", pct))Combat Stats
Local player only
These values are not replicated. All functions return 0 / -1 when called on a non-local player handle.
| Function | Returns | Description |
|---|---|---|
p:get_recoil_angles() | vec3 | Spray-climb recoil accumulator {x=pitch, y=yaw, z=roll} in degrees |
p:get_aim_punch() | vec3 | Per-shot view kick {x=pitch, y=yaw, z=roll} in degrees |
p:get_shot_number() | integer | Monotonic shots-fired counter, ammo-independent |
p:get_ammo() | integer | Rounds currently in the primary weapon's magazine (m_iClip). Drops per shot, resets on reload. 0 both for an empty magazine and before the weapon pointer resolves, so 0 on its own is ambiguous. Pair it with get_shot_number() if you need to tell the two apart |
p:get_last_attack_time() | number | GameTime of the last shot fired |
p:get_hero_damage() | integer | Cumulative hero damage dealt this match. Includes DoT |
p:get_aim_target() | integer | Entity index of the enemy the game considers you to be aiming at, or -1. Toggles rapidly - latch for ~0.5-1s |
p:get_aim_target_player() | player | nil | The same target as a player object, or nil when there is none or it is not a player. Local player only. Toggles the same way, so latch it |
Resists, shields and barrier are not readable this way. They come from p:get_stats(), which needs the internal.
Crouch
Local player only
Crouch state is not replicated to other clients. Returns 0 / false for non-local players.
| Function | Returns | Description |
|---|---|---|
p:get_crouch_fraction() | number | 0.0 = standing, 1.0 = fully crouched. Values between indicate a transition |
p:is_crouched() | boolean | true when crouch fraction > 0.5 |
local me = local_player()
if me:is_crouched() then
print("Crouching: " .. string.format("%.0f%%", me:get_crouch_fraction() * 100))
endGround Normal / Slope
Local player only
Ground normal requires local movement data. Returns {0, 0, 0} / 0 for non-local players.
| Function | Returns | Description |
|---|---|---|
p:get_ground_normal() | vec3 | Surface normal of the ground. {0, 0, 1} on flat ground |
p:get_slope_angle() | number | Slope angle in degrees. 0 = flat, 45 = steep, 90 = wall |
The z component of the ground normal indicates steepness:
z value | Meaning |
|---|---|
1.0 | Flat ground |
0.87 | ~30° slope |
0.71 | ~45° slope |
0.0 | Vertical wall |
local me = local_player()
local slope = me:get_slope_angle()
if slope > 30 then
print("Steep slope: " .. string.format("%.1f°", slope))
end
local gn = me:get_ground_normal()
print(string.format("Surface: (%.2f, %.2f, %.2f)", gn.x, gn.y, gn.z))Screen Space
| Function | Returns | Description |
|---|---|---|
p:screen_box() | table | nil | {x, y, w, h} bounding box on screen |
p:screen_head() | vec2 | nil | {x, y} head position on screen |
p:screen_origin() | vec2 | nil | {x, y} feet position on screen |
Returns nil if the player is off-screen or behind the camera. screen_box() also returns nil when the projected box is under 3 pixels tall. That happens for very distant but perfectly on-screen players, so a nil box is not proof the player is off-screen.
The box is height-driven: w is always h * 0.45. Both corners are anchored to the head bone's world XY (the bottom swaps in the origin's Z), so it does not jitter laterally as the model animates.
Modifier Flags
Fast bitmask check for common status effects.
| Function | Returns | Description |
|---|---|---|
p:has_modifier_flag(flag) | boolean | Check a single modifier_flag bit (EModifierState). Use for status effects: stunned, silenced, etc. |
p:is_(flag) | boolean | Check a single player_flag bit (movement / ground state). Takes a player_flag integer, e.g. p:is_(player_flag.onground) |
Different enums
p:has_modifier_flag and p:is_ take values from different constant tables.
- Status effects (stunned, silenced, rooted …) →
p:has_modifier_flag(modifier_flag.STUNNED) - Movement / ground state →
p:is_(player_flag.onground)
Passing a string to p:is_() is incorrect and will not work.
-- Status effect check
if player:has_modifier_flag(modifier_flag.STUNNED) then
print("target is stunned")
end
-- Ground-state check
if player:is_(player_flag.onground) then
print("player is on the ground")
endSee Types & Constants for the full flag list.
Modifiers (full data)
Full modifier data - use when flag checks are insufficient:
| Function | Returns | Description |
|---|---|---|
p:has_modifier(name_or_token) | boolean | True if any modifier matches. String arg = substring match on RTTI name; integer arg = exact token match |
p:get_modifier(name_or_token) | table | nil | First matching modifier, or nil. Same matching rules as has_modifier |
p:get_modifier_count() | integer | Number of active modifiers |
p:get_modifier_names() | table | Array of modifier names, 1:1 with get_modifier_count(). An entry whose RTTI name didn't resolve is emitted as its hex token instead, e.g. "0x9C02E614". Prefix or substring matching has to tolerate "0x…" strings |
p:get_modifiers() | table | Array of all modifier tables |
-- Substring match: catches modifier_stunned, modifier_delayed_stun, etc.
if player:has_modifier("stun") then ... end
-- Exact token match
if player:has_modifier(0x9C02E614) then ... endEach modifier table (returned by get_modifier and as entries in get_modifiers) has these fields:
| Field | Type | Description |
|---|---|---|
name | string | RTTI class name, e.g. "modifier_glitch" |
token | integer | Ability subclass ID, e.g. 0x9C02E614 |
duration | number | -1 if permanent, > 0 if temporary |
remaining | number | Seconds left (0 if permanent or expired) |
expires_at | number | sim_time() when effect ends. Compare with sim_time(), not game_time() |
is_active | boolean | Has duration and hasn't expired |
ability_name | string | Linked ability class name |
ability_cd | number | Ability cooldown remaining |
ability_cooling | boolean | Ability is on cooldown |
serial | integer | Unique instance ID (distinguishes duplicate tokens) |
caster | player | nil | Who applied the modifier (nil if unknown) |
local m = player:get_modifier("stunned")
if m and m.is_active then
print(m.remaining, "seconds left, applied by", m.caster and m.caster:hero_name())
end
for _, m in ipairs(player:get_modifiers()) do
if m.is_active and m.caster and m.caster:is_enemy() then
print(m.name, m.remaining)
end
endUltimate
| Function | Returns | Description |
|---|---|---|
p:is_ult_trained() | boolean | Ultimate has been skilled |
p:is_ult_ready() | boolean | Trained AND off cooldown |
p:get_ult_cooldown() | number | Seconds remaining (-1 = not trained, 0 = ready) |
Abilities (slots 0-3) & Weapon Slots
Hero abilities, active items, and weapon slots.
| Function | Returns | Description |
|---|---|---|
p:has_abilities() | boolean | Player has any abilities loaded |
p:is_ability_ready(slot_or_name) | boolean | Slot is ready to cast. Accepts a slot integer or an ability class-name substring (case-insensitive, first match wins). false when nothing matches |
p:get_ability(slot) | table | nil | Full ability data for one slot (see field list below) |
p:get_abilities() | table | Lightweight summary of all abilities keyed by slot index - each entry has only {name, slot, points, is_ready, cooldown}. For the full field set use get_ability(slot) |
p:get_selected_ability() | table | nil | The ability or active item you are holding right now, as {slot, name}. nil when the gun is out. Local player only |
-- these are equivalent for Haze
if lp:is_ability_ready(slot.ability1) then ... end
if lp:is_ability_ready("sleep_dagger") then ... endSlot Constants
slot.* | Value | Meaning |
|---|---|---|
slot.ability1 … slot.ability4 | 0-3 | Hero abilities (signature 1-4) |
slot.item1 … slot.item4 | 4-7 | Active items |
slot.weapon_secondary | 20 | Secondary weapon / alt-fire |
slot.weapon_primary | 21 | Primary gun |
slot.weapon_melee | 22 | Melee |
Weapon slots are local player only, and nearly empty
Weapon slots (20-22) resolve only for the local player. Items (4-7) come back through the active-item API rather than get_ability, and item tables carry no projectile_speed field at all.
A weapon-slot ability table fills in only name, slot, projectile_speed (the base gun speed, which is why the slot is exposed), plus learned = true and is_ready = true, both hardcoded. Everything else stays at its default: cooldown 0, points 0, range 0, remaining_charges -1, and none of the phase timestamps are present.
So p:is_ability_ready(slot.weapon_primary) is true whenever the slot has resolved, and cannot be used as a fire gate.
Each ability table has:
| Field | Type | Description |
|---|---|---|
name | string | RTTI name, e.g. "citadel_ability_tengu_airlift" |
slot | integer | Slot number |
points | integer | Upgrade points spent: popcount(upgrade_bits >> 16) - 1. -1 means nothing has been spent. This, not learned, is the skilled/unskilled test |
learned | boolean | Hardcoded true for every ability the table returns, so it is never a useful check. Use points >= 0 |
cooldown | number | Seconds remaining |
is_ready | boolean | Learned, not on cd, not casting |
is_cooling_down | boolean | On cooldown |
is_casting | boolean | Mid-cast |
is_channeling | boolean | Currently channeling |
is_in_cast_delay | boolean | In pre-cast windup |
remaining_charges | integer | Remaining charges (-1 = not charge-based) |
upgrade_bits | integer | Bitmask of purchased upgrade tiers |
projectile_speed | number | Base projectile speed in u/s (VData, not item-boosted). 0 = no projectile |
range | number | Cast range from the ability's VData (AbilityCastRange), in game units. Multiply by 0.0254 for metres. 0 when the ability configures no cast range (self-cast, passive) or its VData hasn't resolved yet. Works on any player handle; weapon slots (20-22) always leave it 0 |
cast_delay_start | number | nil | sim_time() when the cast-delay (windup) began; nil when not active |
channel_start | number | nil | sim_time() when channeling began; nil when not active |
cooldown_start | number | nil | sim_time() when cooldown began; nil when not active |
cooldown_end | number | nil | sim_time() when cooldown ends; nil when not active |
cast_completed | number | nil | sim_time() the cast finished; nil when not active |
is_ready does not imply skilled
is_ready is computed as learned and not cooling down and not in cast delay and not channeling, and learned is always true. So an unskilled ability that happens to be off cooldown reports is_ready = true. Gate on points >= 0 as well before casting.
sim_time domain
All phase timestamps (cast_delay_start, channel_start, cooldown_start, cooldown_end, cast_completed) are in sim_time() domain. To compute elapsed time use sim_time() - ability.cast_delay_start, not game_time().
Projectile Speed
get_ability(slot).projectile_speed returns the base (VData) speed. For the gun's effective speed including velocity items, use get_active_projectile_speed() instead.
local me = local_player()
-- Ability projectile speed (base)
local dagger = me:get_ability(slot.ability1)
if dagger then print(dagger.name, dagger.projectile_speed) end
-- Gun base vs effective
local gun_base = me:get_ability(slot.weapon_primary)
local effective = me:get_active_projectile_speed()
-- effective = base × (1 + 0.6 × velocity_item_count)Owned Items
All purchased items (m_vecUpgrades). Accepts a case-insensitive display name or hex token.
| Function | Returns | Description |
|---|---|---|
p:get_item_count() | integer | Number of owned items |
p:has_item(name_or_token) | boolean | "Cursed Relic", "cursed relic", or 0x9C02E614 all work |
p:get_item(name_or_token) | table | nil | Item table or nil if not owned |
p:get_items() | table | All owned items |
Each item table has:
| Field | Type | Description |
|---|---|---|
token | integer | Always present, e.g. 0x5230D219 |
name | string | nil | Display name, e.g. "Metal Skin". The key is omitted when the token has no known display name, so test it before formatting |
if player:has_item("Cursed Relic") then
-- ...
end
for _, it in ipairs(player:get_items()) do
print(string.format("0x%08X %s", it.token, it.name or "?"))
endActive Items (slots 4-7)
Active items equipped in slots 4-7, each with cooldown state. Match by RTTI class-name substring (lowercase snake_case, e.g. "metal_skin").
| Function | Returns | Description |
|---|---|---|
p:get_active_item_count() | integer | Number of active items equipped |
p:has_active_item(substring) | boolean | RTTI substring match |
p:get_active_item(substring) | table | nil | Single active item data |
p:get_active_items() | table | Array of all active items |
p:is_item_ready(slot_or_name) | boolean | True if the item in the given slot index or name substring is off cooldown and usable |
Each active item table has:
| Field | Type | Description |
|---|---|---|
name | string | RTTI class name |
subclass | string | Subclass token rendered as hex text, e.g. "0x9C02E614", not a readable name. Empty string ("") when the item carries no subclass token |
slot | integer | 4-7 |
bucket | integer | Item bucket |
cooldown | number | Seconds remaining |
is_ready | boolean | Off cooldown, not in cast delay, not channeling. Populated for enemy and teammate handles too, not just the local player |
if player:has_active_item("metal_skin") then
local mskin = player:get_active_item("metal_skin")
print("cd:", mskin.cooldown)
endVData Ability Properties
Read raw KV3 property values from an ability's CitadelAbilityVData. Values are in game units (multiply by 0.0254 for metres). Slot-based reads work on any player handle; name-based variants are local player only.
Static data
VData properties are the base tuning values baked into the game files. They are not affected by items or modifiers. Read them once; polling is unnecessary.
| Function | Returns | Description |
|---|---|---|
p:read_ability_property(slot, prop) | number | nil | Single property value for the ability in slot. nil if not found |
p:get_ability_properties(slot) | table | All properties for the ability in slot as {[name]=value}. Empty table if not resolved |
p:read_ability_property_by_name(class_substr, prop) | number | nil | Single property for an ability matched by class-name substring (case-insensitive). Reaches innate abilities ("melee_parry", "jump", "dash", etc.). Local player only |
p:get_ability_properties_by_name(class_substr) | table | All properties for the ability matched by class-name substring. Local player only |
local me = local_player()
-- Read a known property by slot
local dmg = me:read_ability_property(slot.ability1, "Damage")
if dmg then print("Ability 1 damage:", dmg) end
-- Dump all properties for a slot
for name, val in pairs(me:get_ability_properties(slot.ability2)) do
print(name, val)
end
-- Reach an innate ability by class-name substring (local player only)
local parry_dur = me:read_ability_property_by_name("melee_parry", "ParryDuration")
print("Parry window:", parry_dur)Internal feeds
Five methods need the internal running and needs_internal = true in script_info. Without the flag they return nil. See Internal API Reference.
| Method | What it returns |
|---|---|
p:get_stats() | Resolved combat stats: resists, shields, barrier, and your own weapon numbers |
p:get_ability_value(slot, name) | The resolved value of one ability property, local player only |
p:get_hit_events([since_seq]) | New damage events, the newest sequence number, and how many you missed |
p:get_hit_stats() | Running hit, headshot and damage counters for hits you dealt |
p:get_property(name) | One schema field on this player's entity, typed as the game declares it |