Skip to content

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

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

FailureReason
More than 32 distinct namessubscription refused (table full, or internal not attached)
source outside 0 to 2source must be 0..2
Empty nameempty 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():

lua
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

sourceMeaning
0 (default)Server to client. Almost everything
1Client side game event
2Client 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:

lua
{
  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. nil means not sent, never sent as zero.

  • Strings stop at 128 bytes and set <field>_truncated = true alongside. 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.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, capped at 8 entries, with <field>.n carrying 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 = true appears 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:

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

MessageUseful fields
CCitadelUserMsg_HeroKilledentindex_victim, entindex_killer
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
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().dropped counts 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.

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