Game Messages
The game announces most of what happens over the network: a hero died, damage landed, a bullet hit, someone typed in chat. events.* hands you those announcements directly, so you do not have to poll memory every tick and guess.
This is a different thing from the Events page. That one covers callbacks TSUKI calls on your script, like on_tick and on_kill. This one subscribes to the game's own traffic.
Needs the internal running for messages to arrive. events.on itself works before that, see Subscribing early.
The three calls
local h = events.on("CCitadelUserMsg_HeroKilled", function(e)
print(e.name, e.entindex_victim, e.entindex_killer)
end)
events.off(h) -- stop. 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 | subscription refused (table full, or internal not attached) |
source outside 0 to 2 | source must be 0..2 |
| Empty name | empty name |
Subscribe by name, never by id. Ids are build specific and get renumbered between patches. The engine sometimes decorates a name with a trailing number in brackets; both spellings work.
Subscribing early
Scripts load before the internal attaches, so events.on succeeds and queues. The subscription goes live the moment the channel comes up. You do not need a retry loop and you should not write one.
To tell "not up yet" from "never going to arrive", read events.list():
local info = events.list()
-- info.internal == true the producer is live, messages will arrive
-- info.internal == false nothing fires until the internal loads
-- info.dropped how many were lost to overrun
-- info.subs { { name = ..., source = ..., handle = ... }, ... }If you have subscriptions while internal is false, the console says so once, so a dead feed announces itself instead of looking like a message the game never sends.
Sources
source | Meaning |
|---|---|
0 (default) | Server to client. Almost everything |
1 | Client side game event |
2 | Client to server, what your own game sends |
Source 2 is how you see CCitadelClientMsg_HitMismatch, which carries client_hit_entity_index and server_hit_entity_index. That is the client telling the server its predicted hit disagreed, which is per shot ground truth on silent aim.
The event table
Every handler gets one table:
{
name = "CCitadelUserMsg_ChatMsg",
source = 0,
-- then one entry per field that was present
player_slot = 11,
text = "gg",
all_chat = true,
}The rules are all deliberate:
Absent fields are absent. A field the message did not set does not appear.
nilmeans not sent, never sent as zero.Strings stop at 128 bytes and set
<field>_truncated = truealongside. A silently clipped chat line would read as the player having typed something shorter than they did.Byte fields carry a length only, as
{ kind = "bytes", len = 97 }. The payload is never copied.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, capped at 8 entries, with
<field>.ncarrying the true length so you can see when it was cut:e.player_slots[1],e.player_slots.n.Anything deeper is reported as a count, as
{ kind = "nested", count = 3 }. Reported rather than dropped, so you can tell "no such field" from "there, too complex to flatten".overflow = trueappears when a message had more than 32 fields set.
Finding what actually 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 are not limited to the messages listed below. Subscription works by name across everything the engine declares, so anything in it works. CMsgFireBullets is a good example that is not a Citadel message: it carries shot_id, shooter_entity, origin, and both angles and angles_original, so the recoil difference is free.
Messages worth knowing
A selection. events.discover() will show you the rest.
| Message | Useful fields |
|---|---|
CCitadelUserMsg_HeroKilled | entindex_victim, entindex_killer |
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 |
CCitadelClientMsg_HitMismatch (source 2) | client_hit_entity_index, server_hit_entity_index |
Delivery
- Lossy, and it tells you. The queue holds 32 messages. If your script falls behind, the oldest are overwritten and
events.list().droppedcounts them. A gapped stream that claims to be complete would be worse than a gap you can see. - One batch per cycle. Messages are collected once before any script runs, so every handler for one message sees the same batch.
- Handlers die with their script. Reloading drops the subscription on both sides.
Cost
Nothing at all when no script subscribes. With no subscriptions there is no decoding and no allocation. Decoding happens only for a name somebody asked for.
Example: real damage numbers
Subscribe at the top level of the script. That runs before the internal attaches, which is fine and is the point of the queue.
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 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