Skip to content

RustCraft Lua Modding API

RustCraft loads client-only Lua mods from mods/<mod_id>/manifest.json. Each mod receives an isolated Lua 5.4 state (TABLE | STRING | MATH | UTF8 | COROUTINE). No filesystem, OS, debug, native module, dofile, or loadfile access is available.


1. Mod Structure

mods/
└── old_animations/
    ├── manifest.json          # required
    ├── scripts/
    │   └── client.lua         # entrypoint (paths configured in manifest)
    └── assets/
        └── old_animations/    # mod-namespace asset root
            ├── textures/
            └── shaders/

Manifest

json
{
  "id": "example_mod",
  "name": "Example Mod",
  "version": "1.0.0",
  "api_version": 1,
  "entrypoints": {
    "client": "scripts/client.lua"
  },
  "permissions": [
    "client.read",
    "render.read",
    "render.custom_draw"
  ]
}
FieldDescription
idUnique namespaced identifier (e.g. old_animations)
nameHuman-readable display name
versionSemantic version string
api_versionMust match 1
entrypoints.clientPath to main Lua script, relative to mod root
permissionsList of capability strings (see §12)

2. Permissions

Mods declare requested permissions in manifest.json. Non-sensitive permissions are granted automatically. The following are denied by default and require explicit approval in mods/permissions.json:

  • network.modify — modify packet fields
  • network.cancel — cancel/drop packets
  • network.send — send new packets
  • protocol.translate — register protocol translators
  • resources.register — declare resource registrations

mods/permissions.json

json
{
  "protocol_bridge": ["protocol.translate", "network.modify"],
  "network_tools": ["network.send", "network.cancel"]
}

Full Permission List

PermissionCategorySensitiveGrants
client.readClientNoRead client snapshot, window, settings
client.modifyClientNoSet FOV/bobbing/HUD/camera/fullscreen/title
ui.readUINoRead UI snapshot
ui.modifyUINoShow messages, open/close chat, toggle HUD
input.observeInputNoRead input snapshot, receive input.action events
input.consumeInputNoConsume input edges via game.input.consume()
render.readRenderNoSubscribe to render events
render.modifyRenderNoReceive mutable render context
render.custom_drawRenderNoCall event.draw:*() methods
animation.readAnimationNoRead animation context
animation.modifyAnimationNoCall event.transform:*() methods
resources.readResourcesNoRead mod and provider resources
resources.registerResourcesYesDeclare resource overrides
network.observeNetworkNoSubscribe to packet events, read packet fields
network.modifyNetworkYesModify packet fields, replace packets
network.cancelNetworkYesCancel/drop packets
network.sendNetworkYesSend new packets via game.network.send()
protocol.inspectProtocolNoRead protocol version information
protocol.translateProtocolYesRegister Lua protocol translators
storage.readStorageNoRead persistent key-value data
storage.writeStorageNoWrite persistent key-value data

3. Lifecycle & Hot Reload

lua
function on_load()
    game.log.info("Mod loaded successfully")
end

function on_unload()
    game.log.info("Mod shutting down")
end
  • on_load() — called after the entrypoint script executes
  • on_unload() — called before state is destroyed (reload or shutdown)

Reload commands (in-game chat):

  • /mods reload — reload all mods
  • /mods reload <mod_id> — reload one specific mod

Reload creates a fresh Lua state: all listeners, queued packets, UI commands, and translator registrations are removed. Protocol translator mods cannot reload while connected to a server (will error).


4. Mod Configuration

Mods declare settings with game.config.define(). Values are persisted to mods/<mod_id>/config.json.

lua
local config = game.config.define({
    {
        key = "enabled",
        type = "boolean",
        label = "Enabled",
        description = "Enable mod functionality",
        default = true
    },
    {
        key = "strength",
        type = "number",
        label = "Strength",
        description = "Effect intensity",
        default = 1.0,
        min = 0.0,
        max = 2.0,
        step = 0.1
    },
    {
        key = "style",
        type = "choice",
        label = "Style",
        description = "Visual style preset",
        default = "classic",
        options = {
            { value = "classic", label = "Classic" },
            { value = "subtle", label = "Subtle" }
        }
    }
})

-- Read current values (always returns live data)
local enabled = config.get("enabled")
local strength = config.get("strength")
TypeParametersLua value type
booleandefaultboolean
numberdefault, min, max, stepnumber
choicedefault, options (array of {value, label})string
  • define() is idempotent — calling it again does not overwrite existing entries
  • Values are validated on write; values that fail validation fall back to defaults
  • The in-game Modding screen allows users to edit these values

5. Event System

lua
-- Simple subscription (priority 0)
game.events.on("client.tick", function(event)
    local tick = event.tick
end)

-- Priority subscription
game.events.on("animation.first_person.transform", {
    priority = 500,
    callback = function(event)
        -- higher priority = executed first
    end
})

-- Remove a listener
local handle = game.events.on("event.name", callback)
handle:remove()

Event object methods

lua
function(event)
    event:cancel()        -- mark event as cancelled
    event:consume()       -- consume the event (no further processing)
    event:set_result(v)   -- set a return value
end

Priorities

Priorities run highest-to-lowest (-10000 to 10000):

ConstantValueUse
HIGHEST1000Must run before everything
HIGH500Override defaults
NORMAL0Typical handler
LOW-500Fallback observer
LOWEST-1000Last resort

Error isolation

  • A single callback error does not abort other mods' callbacks
  • Three consecutive errors per mod disables that mod for the session
  • Recovery requires /mods reload

6. Client API (game.client)

Requires: client.read (read functions) or client.modify (modify functions). Each set is installed independently.

Read functions (client.read)

lua
-- Full snapshot (table)
local snap = game.client.snapshot()
-- { tick, frame_delta_seconds, fps, active_screen, paused,
--   window, settings, connection, player_present }

-- Window info
local win = game.client.window()
-- { width, height, framebuffer_width, framebuffer_height,
--   scale_factor, focused, fullscreen }

-- Client settings (respects active script overrides)
local s = game.client.settings()
-- { fov_degrees, gui_scale, view_bobbing, hud_visible, camera_mode }

-- Connection status
local conn = game.client.connection()
-- { state, connected, server_address, protocol_version,
--   protocol_name, latency_ms, encrypted, server_brand }

-- Boolean checks
if game.client.is_connected() then ... end

State values: "disconnected", "connecting", "login", "play"

Screen IDs: "main_menu", "game", "pause", "inventory", "chat", "sign_editor", "disconnected", "connecting", "options", "video_settings", "controls", "skin_customization", "language", "audio_settings", "resource_packs", "modding", "mod_config", "multiplayer", "direct_connect", "server_editor", "alt_manager", "loading_world"

Modify functions (client.modify)

lua
-- FOV override (30.0 to 110.0, nil to reset)
game.client.set_fov_override(90.0)
game.client.set_fov_override(nil)

-- View bobbing override (nil to reset)
game.client.set_view_bobbing_override(true)

-- HUD visibility override (nil to reset)
game.client.set_hud_visibility_override(false)

-- Camera mode override
game.client.set_camera_mode_override("third_person_back")
-- Values: "first_person", "third_person_back", "third_person_front"
game.client.set_camera_mode_override(nil)

-- Fullscreen toggle
game.client.set_fullscreen(true)

-- Window title (max 256 chars, no control chars)
game.client.set_window_title("RustCraft - Custom")
game.client.set_window_title(nil)

All overrides are applied per-mod in load order; the last enabled mod wins for each setting.


7. Player API (game.player)

Requires: client.read

lua
-- Check presence
if game.player.exists() then ... end

-- Full snapshot (nil when not in-game)
local p = game.player.snapshot()
-- {
--   entity_id, name, gamemode, gamemode_name, dimension,
--   position: { x, y, z },
--   previous_position: { x, y, z },
--   velocity: { x, y, z },
--   rotation: { yaw, pitch, body_yaw, head_yaw },
--   movement: { on_ground, collided_horizontally, sneaking, sprinting,
--               jumping, in_water, in_lava, fall_distance,
--               input_strafe, input_forward },
--   action: { using_item, use_action, use_ticks, blocking,
--             swinging, swing_progress },
--   capabilities: { invulnerable, creative_mode, allow_flying,
--                   flying, walk_speed, fly_speed },
--   vitals: { health, max_health, absorption, food, saturation, oxygen },
--   experience: { level, progress, total },
--   selected_hotbar_slot
-- }

-- Individual accessors (nil when player absent)
local pos = game.player.position()     -- { x, y, z }
local vel = game.player.velocity()     -- { x, y, z }
local rot = game.player.rotation()     -- { yaw, pitch, body_yaw, head_yaw }
local mov = game.player.movement()     -- { on_ground, ... }
local act = game.player.action()       -- { blocking, swing_progress, ... }
local cap = game.player.capabilities() -- { creative_mode, flying, ... }
local vit = game.player.vitals()       -- { health, food, ... }

use_action values: "block" (sword), "drink" (potion), "bow", "eat" (food), "use" (other) gamemode_name values: "survival", "creative", "adventure", "spectator"


8. Input API (game.input)

Requires: input.observe

lua
-- Check if an action is currently held
local held = game.input.held("attack")       -- boolean

-- Check per-edge state
local pressed = game.input.pressed("jump")   -- just pressed this tick
local released = game.input.released("jump") -- just released this tick

-- Mouse delta since last tick
local dx, dy = game.input.mouse_delta()

-- Cursor capture state
local captured = game.input.cursor_captured()

Input consume (input.consume)

lua
-- Consume an input edge, preventing the game from processing it
local consumed = game.input.consume("forward", "pressed")

Action names

forward, backward, strafe_left, strafe_right, jump, sneak, sprint, toggle_sprint, attack, use, inventory, drop_item, hotbar_1hotbar_9, hotbar_next, hotbar_previous, chat, command, player_list, pause, toggle_flying, toggle_perspective

input.action event

Fires when any action changes state:

lua
game.events.on("input.action", function(event)
    -- event.action   = "attack"
    -- event.edge     = "pressed" | "released"
    -- event.held     = true | false
    -- event.repeat   = true | false
end)

Requires: input.observe


9. UI API (game.ui)

Requires: ui.read (snapshot) and/or ui.modify (commands).

Snapshot (ui.read)

lua
local ui = game.ui.snapshot()
-- {
--   screen:       { id, title, in_game, paused },
--   chat:         { open, input, visible_messages, unread_messages },
--   inventory:    { open, window_id, kind, title, slot_count,
--                   selected_hotbar_slot, cursor_item, slots },
--   gui:          { hud_visible, crosshair_visible, chat_visible,
--                   debug_visible, scale, focused_widget },
--   window:       { width, height, framebuffer_width, framebuffer_height,
--                   scale_factor, focused, fullscreen }
-- }

-- Inventory slot: { slot, id, count, damage, display_name }

Commands (ui.modify)

lua
-- Show a system message in chat
game.ui.show_system_message("Hello from Lua!")

-- Open/close chat
game.ui.open_chat("/command")     -- optional initial text
game.ui.close_chat()

-- Toggle HUD visibility
game.ui.set_hud_visible(false)

-- Toggle crosshair
game.ui.set_crosshair_visible(false)

10. World API (game.world)

Requires: client.read

lua
-- Summary (dimension, time, weather, chunk count)
local info = game.world.summary()
-- { dimension_id, dimension_name, game_time, day_time,
--   weather: { raining, thundering, rain_strength, thunder_strength },
--   loaded_chunks, player_position }

-- Query block at position (returns nil if out of range)
local block = game.world.block_at(x, y, z)
-- { state, sky_light, block_light, biome } or nil

-- Batch block query (radius ≤ 8, max 4096 results)
local blocks = game.world.blocks_around(x, y, z, radius)
-- table of { x, y, z, state, sky_light, block_light, biome }

-- Entity query (radius ≤ 128, max 128 results)
local entities = game.world.entities_around(x, y, z, radius)
-- table of { id, kind, name, x, y, z, vx, vy, vz, yaw, pitch,
--            on_ground, health, max_health }

Entity kind names are snake_case, e.g. "zombie", "cave_spider", "xp_orb", "minecart_tnt".


11. Network API (game.network)

Packet observation (network.observe)

lua
-- Subscribe to all inbound/outbound packets
game.events.on("network.packet.inbound", function(event)
    local p = event.packet
    game.log.info(string.format("[%s] %s", p.direction, p.name))
end)

-- Filtered subscription
game.network.on_packet({
    direction = "inbound",   -- "inbound" | "outbound"
    names = { "clientbound_chat_message", "clientbound_disconnect" },
    priority = 500,
    callback = function(event)
        local p = event.packet
    end
})

Packet object

lua
{
    direction = "inbound" | "outbound",
    state     = "handshake" | "status" | "login" | "play",
    version   = 47,
    id        = 0x02,
    name      = "clientbound_chat_message",
    fields    = { ... }  -- decoded, version-specific object
}

Modify packets (network.modify)

lua
game.events.on("network.packet.inbound", function(event)
    -- Change a field
    event.packet.fields.content = "modified text"

    -- Replace entire packet
    event:replace({
        name = "clientbound_system_chat",
        fields = {
            content = "replaced",
            overlay = false
        }
    })
end)

Cancel packets (network.cancel)

lua
game.events.on("network.packet.outbound", function(event)
    event:cancel()  -- prevents the packet from being sent
end)

Send packets (network.send)

lua
-- Max 20 per mod per tick
game.network.send({
    name = "serverbound_chat_message",
    fields = {
        message = "Hello from Lua mod!"
    }
})
  • v47 chat messages are limited to 100 bytes
  • Custom payloads limited to 32,767 bytes
  • Unknown fields are rejected by the version codec
  • All modifications/cancellations are logged in a dev audit trail
  • Auth/session tokens, raw bytes, and encrypted data are never exposed

Connection events

lua
game.events.on("network.state_changed", function(event)
    -- event.previous, event.current
end)

game.events.on("network.disconnect", function(event)
    -- event.reason
end)

12. Protocol Translators (game.protocol)

Requires: protocol.translate (sensitive — must be approved in permissions.json)

lua
game.protocol.register_translator({
    id = "example:legacy_chat",   -- namespaced, unique per mod
    source = 47,                  -- source protocol version
    target = 107,                 -- target protocol version

    inbound = function(packet, context)
        -- context: { source_version, target_version }
        if packet.name == "clientbound_chat_message" then
            return {
                id = 0x0F,
                name = "clientbound_system_chat",
                fields = {
                    content = packet.fields.message,
                    overlay = false
                }
            }
        end
        return packet
    end,

    outbound = function(packet, context)
        return packet
    end
})

Return values from translator callbacks

ReturnMeaning
nilDrop the packet
Single packet tableReplace with this packet
Sequence of packet tablesSplit into multiple packets (max 16)
  • Total pipeline limit: 64 packets per translation chain
  • Packet fields must serialize to ≤ 1 MiB JSON
  • Translator IDs are namespaced (e.g. "mod_id:translator_name"), duplicates rejected
  • Outbound translators run in reverse pipeline order

13. First-Person Animation API

Events

lua
game.events.on("animation.first_person.transform", function(event)
    -- event.hand    = "main_hand" | "off_hand"
    -- event.state   = { equip_progress, swing_progress, use_progress,
    --                    attack_cooldown, using_item, blocking, sneaking,
    --                    yaw, pitch, partial_tick }

    if event.hand == "main_hand" and event.state.blocking then
        event.transform:translate(-0.08, 0.16, -0.12)
        event.transform:rotate_y(28)
        event.transform:rotate_x(-18)
        event.transform:rotate_z(-10)
    end
end)

Additional events:

  • animation.first_person.calculate — before transforms
  • animation.first_person.arm_transform — arm-only transform
  • animation.first_person.item_transform — item-only transform
  • animation.first_person.complete — after all transforms

Transform API

lua
event.transform:reset()                    -- reset to identity
event.transform:translate(x, y, z)         -- pre-multiply translation
event.transform:rotate_x(degrees)          -- rotate around X axis
event.transform:rotate_y(degrees)          -- rotate around Y axis
event.transform:rotate_z(degrees)          -- rotate around Z axis
event.transform:rotate_axis(x, y, z, deg)  -- rotate around arbitrary axis
event.transform:scale(sx, sy, sz)          -- pre-multiply scale
event.transform:mul(matrix_table)          -- 4×4 manual matrix {11..14, 21..24, 31..34, 41..44}

Transform semantics

All methods pre-multiply onto the existing model matrix. The rendering pipeline is:

projection * view * model * position

Lua transforms are applied to the model matrix in call order. For example:

lua
event.transform:translate(1, 0, 0)   -- model' = translate * model
event.transform:rotate_y(45)         -- model'' = rotate * translate * model

Transform validation

  • All components must be finite f32 values
  • NaN/Infinity values are rejected
  • Each component is clamped to [-1e6, 1e6]

14. HUD Drawing API (event.draw)

Requires: render.custom_draw

Available in render.hud.before and render.hud.after events. Commands are frame-local and expire after rendering.

lua
game.events.on("render.hud.after", function(event)
    -- Text (max 4096 chars)
    event.draw:text({
        text = "Hello World",
        x = 8, y = 8,
        scale = 1.0,                       -- 0.1 to 16.0
        color = { r = 1.0, g = 1.0, b = 1.0, a = 1.0 }
    })

    -- Filled rectangle
    event.draw:rect({
        x = 6, y = 24,
        width = 80, height = 12,
        color = { r = 0, g = 0, b = 0, a = 0.5 }
    })

    -- Line
    event.draw:line({
        x1 = 0, y1 = 0, x2 = 100, y2 = 100,
        width = 2.0,                       -- 0.1 to 128.0
        color = { r = 1.0, g = 0, b = 0, a = 1.0 }
    })

    -- Image
    event.draw:image({
        resource = "minecraft:textures/gui/widgets.png",
        x = 0, y = 0,
        width = 256, height = 256,
        u = 0, v = 0, uw = 1.0, vh = 1.0,
        color = { r = 1.0, g = 1.0, b = 1.0, a = 1.0 }
    })

    -- Transform stack
    event.draw:push_transform()
    event.draw:translate(tx, ty)
    event.draw:rotate(degrees)
    event.draw:scale(sx, sy)
    event.draw:pop_transform()

    -- Scissor rectangle (nil to disable)
    event.draw:set_scissor({
        x = 0, y = 0,
        width = 200, height = 100
    })
    event.draw:set_scissor(nil)
end)

Limits

  • 4,096 draw commands per frame total (before + after combined per mod)
  • Text max 4,096 bytes
  • Color components must be finite and within [0, 10]
  • Image resources validated against built-in atlas: minecraft:textures/gui/widgets.pngminecraft:textures/gui/icons.pngminecraft:textures/gui/container/inventory.png

15. Render Events

EventPermissionDescription
render.frame.beginrender.readFrame rendering starts
render.world.beforerender.readBefore world geometry
render.world.afterrender.readAfter world geometry
render.entities.beforerender.readBefore entity rendering
render.entities.afterrender.readAfter entity rendering
render.hand.beforerender.readBefore first-person hand
render.hand.afterrender.readAfter first-person hand
render.hud.beforerender.readBefore HUD overlay
render.hud.afterrender.readAfter HUD overlay
render.frame.endrender.readFrame rendering ends

The render.hud.before/after events provide event.draw with render.custom_draw permission.

The render.hand.transform event provides event.transform for hand mesh modification.


16. Resources API (game.resources)

Requires: resources.read and/or resources.register

lua
-- Read text resource from mod's assets folder
local text = game.resources.read_text("old_animations:textures/example.json")

-- Read binary resource
local bytes = game.resources.read_binary("old_animations:sounds/click.ogg")

-- Resolve a registered resource ID to its current target
local path = game.resources.resolve("old_animations:textures/custom.png")

Resource IDs follow namespace:path format. The namespace is the mod ID. Resources are read from mods/<mod_id>/assets/<mod_id>/ directory.

Limits:

  • Binary: ≤ 1 MiB
  • Text: ≤ 256 KiB
  • Resource ID: ≤ 256 bytes

17. Storage API (game.storage)

Requires: storage.read and/or storage.write

Persistent key-value JSON storage scoped to mods/<mod_id>/data/storage.json. Data is read fresh before every operation; no stale in-memory snapshots.

lua
-- Read a value
local value = game.storage.read("my_key")

-- Write a value (supports tables, strings, numbers, booleans, nil)
game.storage.write("my_key", { count = 42, name = "example" })

-- Delete a key
game.storage.delete("my_key")

-- List all keys
local keys = game.storage.keys()

-- Clear all stored data
game.storage.clear()

Limits

LimitValue
Total file size512 KiB
Max entries512
Max key length128 bytes
Max value depth16
Max value nodes4,096
Total nodes16,384
Max container entries1,024
Max string length64 KiB

18. Logging (game.log)

lua
game.log.debug("Detailed debug information")
game.log.info("General information")
game.log.warn("Warning message")
game.log.error("Error occurred!")

Output format: [Lua/<LEVEL>/<mod_id>] message


19. Lifecycle Events

EventWhenPermission
client.tickEvery game tick (20/s when playing)client.read
lua
game.events.on("client.tick", function(event)
    -- event.tick, event.delta_time, event.playing
end)

20. Complete Event Reference

EventDataPermission
client.tick{ tick, delta_time, playing }client.read
input.action{ action, edge, held, repeat }input.observe
network.packet.inboundevent.packet (table)network.observe
network.packet.outboundevent.packet (table)network.observe
network.state_changed{ previous, current }network.observe
network.disconnect{ reason }network.observe
render.frame.begin{ frame } (context)render.read
render.world.before{ frame }render.read
render.world.after{ frame }render.read
render.entities.before{ frame }render.read
render.entities.after{ frame }render.read
render.hand.before{ frame }render.read
render.hand.after{ frame }render.read
render.hud.before{ frame, draw }render.read (+ custom_draw for draw)
render.hud.after{ frame, draw }render.read (+ custom_draw for draw)
render.frame.end{ frame }render.read
render.entity.before{ frame, entity }render.read
render.entity.after{ frame, entity }render.read
animation.first_person.calculate{ hand, state }animation.read
animation.first_person.transform{ hand, state, transform }animation.read/modify
animation.first_person.arm_transform{ hand, state, transform }animation.read/modify
animation.first_person.item_transform{ hand, state, transform }animation.read/modify
animation.first_person.complete{ hand, state }animation.read

21. Safety & Limitations

What Lua CAN do

  • Read client/player/world/UI snapshots
  • Modify FOV, camera mode, view bobbing, window title, fullscreen state
  • Draw HUD overlays (text, rect, line, image)
  • Modify first-person arm/item transforms
  • Observe structured packet data by name
  • Modify, cancel, replace, and send packets (with explicit permission)
  • Register protocol version translators (with explicit permission)
  • Read/write scoped persistent storage
  • Read mod-asset and registered resources
  • Log debug/info/warn/error messages

What Lua CANNOT do

  • Access Vulkan handles, GPU buffers, or raw pointers
  • Access filesystem, OS, or debug APIs
  • Execute native code or load dynamic libraries
  • Access authentication tokens or session secrets
  • Modify player movement, position, or inventory
  • Send raw bytes over the network
  • Access encrypted/compressed packet data
  • Bypass server anticheat checks
  • Spoof player identity or forge authentication

Runtime Limits

LimitDefault
Memory per mod16 MiB
Instructions per callback250,000
Consecutive error threshold3
Max draw commands per frame4,096
Max client commands queue1,024 total / 128 per mod
Max packets sent per tick20 per mod
Max translated packets per stage16
Max pipeline packets64

22. Example: Complete Mod

lua
-- mods/example/scripts/client.lua

local config = game.config.define({
    { key = "enabled", type = "boolean", label = "Enabled", default = true },
    { key = "message", type = "choice", label = "Greeting", default = "hello",
      options = {
        { value = "hello", label = "Hello" },
        { value = "welcome", label = "Welcome" }
      }
    }
})

function on_load()
    game.log.info("Example mod loaded!")
end

function on_unload()
    game.log.info("Example mod shutting down...")
end

-- Observe chat packets
game.network.on_packet({
    direction = "inbound",
    names = { "clientbound_chat_message" },
    callback = function(event)
        local text = event.packet.fields.content or ""
        game.log.info(string.format("Chat: %s", text))
    end
})

-- Draw HUD indicator
game.events.on("render.hud.after", function(event)
    if config.get("enabled") then
        local msg = config.get("message") == "hello" and "Hello!" or "Welcome!"
        event.draw:text({
            text = msg,
            x = 8, y = 8,
            scale = 1.0,
            color = { r = 0.5, g = 1.0, b = 0.5, a = 0.85 }
        })
    end
end)

-- Custom block animation
game.events.on("animation.first_person.transform", {
    priority = 500,
    callback = function(event)
        if not config.get("enabled") then return end
        if event.hand ~= "main_hand" then return end
        if not event.state.blocking then return end

        local swing = math.sin(math.sqrt(event.state.swing_progress) * math.pi)

        event.transform:translate(-0.08, 0.16, -0.12)
        event.transform:rotate_y(28)
        event.transform:rotate_x(-18)
        event.transform:rotate_z(-10)

        event.transform:translate(
            -0.12 * swing,
             0.06 * swing,
            -0.08 * swing
        )
    end
})