Skip to content

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 ​

lua
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:

FailureReason
More than 32 distinct names across all scripts, or a name of 64 characters or moresubscription refused (table full, or internal not attached)
source outside 0 to 2source must be 0..2
source 1source 1 (client game events) is not available on this build
source 2source 2 (client -> server messages) is not available on this build
Empty nameempty 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():

lua
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:

lua
{
  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. nil means the field was not sent. A field sent as zero is never nil.

  • Strings stop at 127 bytes. When a string is cut, <field>_truncated = true is 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.x works:

    lua
    events.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>.n holds 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 = true appears when a message had more than 32 fields set.

  • age_ms is 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 large age_ms. If your script reacts to things as they happen, skip old ones:

    lua
    events.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:

lua
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)
end

You 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.

MessageUseful fields
CCitadelUserMsg_HeroKilledentindex_victim, entindex_attacker, entindex_assisters
CCitadelUserMessage_Damagedamage, pre_damage, entindex_victim, entindex_attacker, entindex_ability, victim_health_new, victim_health_max, crit_damage, hitgroup_id
CCitadelUserMessage_BulletHitshotid, pellet, hit_entindex, weapon_entindex, is_predicted
CCitadelUserMessage_MeleeHithit_entindex, heavy
CCitadelUserMessage_AbilityNotifyentindex_victim, entindex_attacker, ability_id, status_impact
CCitadelUserMsg_AbilityInterruptedentindex_victim, entindex_interrupter, ability_id_interrupted
CCitadelUserMessage_ModifierAppliedentindex_caster, entindex_parent, serial_number
CCitadelUserMessage_CurrencyChangeduserid, currency_type, delta, new_value, entindex_victim
CCitadelUserMessage_ItemPurchaseNotificationuserid, ability_id, sell, quickbuy
CCitadelUserMsg_ChatMsgplayer_slot, text, all_chat
CCitadelUserMsg_BossKilledobjective_team, entity_killed, entity_killer, bosses_remaining
CCitadelUserMessage_GameOverwinning_team
CMsgFireBulletsshot_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().dropped counts 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.

lua
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