Game Messages
events.* passes the game's own network messages to your script: kills, damage, bullet hits, chat and more.
Callbacks TSUKI calls on your script, like on_tick and on_kill, are on the Events page.
Messages only arrive while the internal is running. You can call events.on before that. See Subscribing early.
The three calls
local h = events.on("CCitadelUserMsg_HeroKilled", function(e)
print(e.name, e.entindex_victim, e.entindex_attacker)
end)
events.off(h) -- stop. Returns true if the handle was live
local info = events.list()events.on(name, fn, [source]) returns a handle, or nil and a reason:
| Failure | Reason |
|---|---|
| More than 32 distinct names across all scripts, or a name of 64 characters or more | subscription refused (table full, or internal not attached) |
source outside 0 to 2 | source must be 0..2 |
source 1 | source 1 (client game events) is not available on this build |
source 2 | source 2 (client -> server messages) is not available on this build |
| Empty name | empty name |
Subscribe by name, never by id. Ids are build specific and change between patches. The engine sometimes adds a trailing number in brackets to a name. Subscribe with the bare name. The bracketed spelling is accepted, but its handler never fires.
Your handlers stop for good when your script is turned off or errors. You do not need to call events.off for that. A handler can call events.on and events.off itself.
Handlers also pause while your script is paused: while is_enabled() returns false, or while you are playing a hero the script is not for. Messages that arrive during the pause are not delivered later.
Subscribing early
Scripts load before the internal attaches. events.on still succeeds, and the subscription starts working as soon as the internal is up. No retry loop is needed.
To tell "not up yet" from "never going to arrive", read events.list():
local info = events.list()
-- info.internal == true the internal is running, messages will arrive
-- info.internal == false nothing fires until the internal loads
-- info.dropped how many were lost because the queue was full
-- info.subs { { name = ..., source = ..., handle = ... }, ... }If you have subscriptions while internal is false, the console says so once.
Sources
Only source 0 works: messages from the server to your client. It is the default and carries almost everything. Sources 1 (client side game events) and 2 (what your own game sends) are not available, and events.on returns nil and a reason for them.
The event table
Every handler gets one table:
{
name = "CCitadelUserMsg_ChatMsg",
source = 0,
age_ms = 3,
-- then one entry per field that was present
player_slot = 11,
text = "gg",
all_chat = true,
}How the fields work:
Unset fields are missing. If the message did not set a field, it is not in the table.
nilmeans the field was not sent. A field sent as zero is nevernil.Strings stop at 127 bytes. When a string is cut,
<field>_truncated = trueis set next to it.Byte fields only give a length, as
{ kind = "bytes", len = 97 }. You never get the bytes themselves.One level of nesting is expanded. A position arrives as a real table, so
e.origin.xworks:luaevents.on("CCitadelUserMsg_TriggerDamageFlash", function(e) print(e.flash_position.x, e.flash_position.y, e.flash_position.z) end)Repeated numbers become arrays of at most 8 entries.
<field>.nholds the full length, so you can see when the array was cut:e.player_slots[1],e.player_slots.n.Anything deeper shows up as a count, as
{ kind = "nested", count = 3 }, so you can tell a missing field from one that is too complex to flatten.overflow = trueappears when a message had more than 32 fields set.age_msis how long ago the message arrived, in milliseconds. Normally it is a few ms. After a pause, for example when you alt-tab out of the game, queued messages are still delivered, but with a largeage_ms. If your script reacts to things as they happen, skip old ones:luaevents.on("CCitadelUserMsg_HeroKilled", function(e) if e.age_ms and e.age_ms > 200 then return end -- too old to react to -- react here end)
Finding what fires
Most messages never appear in a given match. events.discover() counts the ones that do:
events.discover(true) -- start counting
-- play for a bit
local c = events.discover()
for _, row in ipairs(c.seen) do -- { name, count, source }
print(row.name, row.count)
endYou can subscribe by name to any message the engine declares, not only the ones listed below. CMsgFireBullets is not a Citadel message. It carries shot_id, shooter_entity, origin, and both angles and angles_original, which give you the recoil difference.
Useful messages
This is only a selection. events.discover() shows you the rest.
| Message | Useful fields |
|---|---|
CCitadelUserMsg_HeroKilled | entindex_victim, entindex_attacker, entindex_assisters |
CCitadelUserMessage_Damage | damage, pre_damage, entindex_victim, entindex_attacker, entindex_ability, victim_health_new, victim_health_max, crit_damage, hitgroup_id |
CCitadelUserMessage_BulletHit | shotid, pellet, hit_entindex, weapon_entindex, is_predicted |
CCitadelUserMessage_MeleeHit | hit_entindex, heavy |
CCitadelUserMessage_AbilityNotify | entindex_victim, entindex_attacker, ability_id, status_impact |
CCitadelUserMsg_AbilityInterrupted | entindex_victim, entindex_interrupter, ability_id_interrupted |
CCitadelUserMessage_ModifierApplied | entindex_caster, entindex_parent, serial_number |
CCitadelUserMessage_CurrencyChanged | userid, currency_type, delta, new_value, entindex_victim |
CCitadelUserMessage_ItemPurchaseNotification | userid, ability_id, sell, quickbuy |
CCitadelUserMsg_ChatMsg | player_slot, text, all_chat |
CCitadelUserMsg_BossKilled | objective_team, entity_killed, entity_killer, bosses_remaining |
CCitadelUserMessage_GameOver | winning_team |
CMsgFireBullets | shot_id, shooter_entity, origin, angles, angles_original |
Delivery
- Messages can be lost. The queue holds 32 messages. When more arrive between two ticks, the oldest are overwritten and
events.list().droppedcounts them. - One batch per cycle. Messages are collected once before any script runs, so every handler for one message sees the same batch.
- Handlers end with their script. Reloading removes the subscription.
Example: real damage numbers
Subscribe at the top level of the script. That code runs before the internal attaches, and the subscription waits until it does.
local recent = {}
events.on("CCitadelUserMessage_Damage", function(e)
table.insert(recent, 1, {
amount = e.damage,
crit = e.crit_damage,
victim = e.entindex_victim,
t = game_time(),
})
recent[9] = nil
end)
function on_tick()
local me = local_player()
local target = me and me:get_aim_target()
if not target or target < 0 then return end
local y = 300
for _, hit in ipairs(recent) do
if hit.victim == target and game_time() - hit.t < 3 then
local col = (hit.crit or 0) > 0 and draw.color(255, 190, 70) or draw.color(230, 230, 230)
draw.text(60, y, string.format("%d", hit.amount), col)
y = y + 16
end
end
end