Skip to content

Modding Guide

This guide covers everything you need to create mods for Rawframe. For the complete function reference, see the API Playground.


What is a Mod?

A Rawframe mod is a self-contained package of Luau scripts that adds gameplay content to the engine. Mods can:

  • Spawn and control entities (cubes, players, projectiles, etc.)
  • Send and receive network messages between server and client
  • Play 2D and 3D positional audio
  • Register per-frame tick callbacks for game logic
  • Perform physics raycasts for hit detection
  • Manage teams, spawns, weapons, and game states

The engine itself ships with no built-in gameplay. All game content comes from mods — gamemodes, weapons, maps, and other content are all community-created.

Each mod runs in its own sandboxed Luau VM (per-mod isolation) with memory limits (16 MB default, configurable) and instruction limits (1M per execute). If a mod script errors, the engine logs the error and continues running. A mod crash will never crash the engine or affect other mods.


Directory Structure

Place your mod inside the mods/ directory at the engine root:

mods/
  my_mod/
    mod.json                  -- Manifest (required)
    scripts/
      shared/                 -- Scripts loaded on BOTH server and client
        config.lua
      server/                 -- Server-only scripts
        init.lua
        weapons.lua
      client/                 -- Client-only scripts
        init.lua
        hud.lua
        input.lua
    sounds/                   -- Audio files (OGG Vorbis)

Key points:

  • mod.json is the only required file. Without it, the engine ignores the directory.
  • scripts/shared/ scripts are loaded first on both sides. Use them for configuration tables, constants, and utility functions.
  • scripts/server/ scripts run only on the dedicated server. They have access to net.Send() and net.Broadcast().
  • scripts/client/ scripts run only on the client. They have access to net.SendToServer(), audio playback, mouse input, and aim direction.

mod.json Format

Every mod must have a mod.json file in its root directory:

{
    "name": "my_mod",
    "version": "1.0.0",
    "description": "My first mod",
    "author": "YourName",
    "dependencies": [],
    "scripts": {
        "server": ["scripts/server/init.lua"],
        "client": ["scripts/client/init.lua"],
        "shared": ["scripts/shared/config.lua"]
    }
}

Field Reference

FieldTypeRequiredDescription
namestringYesUnique mod identifier. Use snake_case.
versionstringYesSemantic version (e.g. "1.0.0").
descriptionstringYesHuman-readable description.
authorstringYesAuthor name or team name.
dependenciesstring[]YesMod names this mod depends on. Empty [] if none.
optional_dependenciesstring[]NoMods loaded before this one if present, skipped if missing.
engine_versionstringNoEngine version constraint (e.g. ">=0.18.0").
scripts.serverstring[]YesServer-side script paths. Supports glob patterns.
scripts.clientstring[]YesClient-side script paths. Supports glob patterns.
scripts.sharedstring[]YesShared script paths loaded on both sides. Supports glob patterns.

Script Glob Patterns

Script paths support glob patterns for automatic file discovery:

{
    "scripts": {
        "shared": ["scripts/shared/config.lua", "scripts/shared/utils/*.lua"],
        "server": ["scripts/server/**/*.lua"],
        "client": ["scripts/client/**/*.lua"]
    }
}
  • scripts/*.lua — all .lua files in scripts/ (single level)
  • scripts/**/*.lua — all .lua files recursively
  • Expanded files are sorted alphabetically for deterministic load order

Script Loading Order

The engine loads mod scripts in a deterministic order:

  1. Dependency resolution — Mods are topologically sorted. If mod B depends on mod A, all of A’s scripts run before any of B’s scripts.
  2. Per-mod loading order: Shared scripts load first, then side-specific scripts (server or client).
  3. Per-mod VM isolation — Each mod runs in its own Luau VM. Globals set by one mod are not visible to other mods.

Per-Mod VM Isolation

Every mod gets its own Lua VM with independent memory and instruction limits:

  • Crash isolation — A buggy mod cannot corrupt another mod’s state.
  • Clean hot-reload — Reloading a mod destroys and recreates only its VM.
  • Memory accountability — Each mod’s memory usage is tracked separately.

Cross-VM hook dispatch: When a mod calls hook.Run("MyEvent", ...), the engine dispatches the hook to all mod VMs. Arguments are serialized via LuaValue (supports nil, bool, number, string, Vec3, and nested tables).

Cross-VM function calls: Use mod.call("other_mod", "fn_name", ...) to call exported functions in another mod’s VM:

-- In mod_a: export a function
mod.export("get_config", function()
    return { max_health = 100, respawn_time = 3.0 }
end)

-- In mod_b: call it
local config = mod.call("mod_a", "get_config")
local max_hp = config.max_health  -- 100

Note: Functions, userdata, coroutines, and metatables cannot cross VM boundaries. Pass data as primitives, strings, Vec3, or tables of these types.


Entity Lifecycle

Entities are the fundamental building blocks of a Rawframe world. Every object (player, cube, projectile, NPC) is an entity with components.

-- Spawn a named entity
local ent = entity.create("MyCube")

-- Configure it
entity.set_position(ent, 10, 5, -3)
entity.set_rotation(ent, 0, 45, 0)
entity.set_scale(ent, 2, 2, 2)
entity.set_mesh(ent, "cube")

-- Read properties (multiple return values)
local x, y, z = entity.get_position(ent)

-- Destroy when done
entity.destroy(ent)

After destruction, any further operations on the entity ID will raise a Luau error.


Networking

Rawframe provides a built-in net library for mod-to-mod network messaging.

Architecture

  • Server can net.Send(peer_id) to one client or net.Broadcast() to all.
  • Client can net.SendToServer() to send messages to the server.
  • Messages are identified by string names and use a binary stream (write in order, read in same order).

Sending Messages

-- Server: send to specific client
net.Start("PlayerDamaged")
net.WriteFloat(new_health)
net.Send(target_peer_id)

-- Server: broadcast to all
net.Start("ScoreboardUpdate")
net.WriteFloat(player_count)
net.Broadcast()

-- Client: send to server
net.Start("PlayerShoot")
net.WriteVec3(aim_x, aim_y, aim_z)
net.SendToServer()

Receiving Messages

-- Server-side: receive from clients
net.Receive("PlayerShoot", function(len, sender)
    local x, y, z = net.ReadVec3()
    rawframe.log("Player " .. tostring(sender) .. " fired!")
end)

-- Client-side: receive from server
net.Receive("PlayerDamaged", function(len)
    local health = net.ReadFloat()
    rawframe.log("New health: " .. tostring(health))
end)

Write/Read Functions

WriteReadType
net.WriteString(s)net.ReadString()string
net.WriteInt(n)net.ReadInt()32-bit integer
net.WriteFloat(n)net.ReadFloat()32-bit float
net.WriteVec3(x,y,z)net.ReadVec3()3D vector
net.WriteBool(b)net.ReadBool()boolean
net.WriteTable(t)net.ReadTable()table

Important: Always call net.Start() before any Write function. Read data in the same order it was written.


Audio

The audio system provides cross-platform 3D positional sound.

-- 2D sound (same volume everywhere)
local handle = audio.play("sounds/click.ogg")

-- 3D positional sound (attenuates with distance)
local handle = audio.play_3d("sounds/explosion.ogg", 10, 0, -5)

-- Control playback
audio.stop(handle)
audio.set_volume(handle, 0.5)
audio.set_master_volume(0.8)

Supported formats: OGG Vorbis (recommended), WAV, MP3, FLAC.

On the server, all audio functions are no-ops — shared scripts can safely call audio functions without side-checking.


Hook System

The hook system provides priority-based event handling across all mods:

-- Register a hook
hook.Add("PlayerSpawn", "my_mod_spawn", function(peer_id)
    rawframe.log("Player " .. tostring(peer_id) .. " spawned!")
end)

-- Fire a hook (dispatched to all mods)
hook.Run("MyCustomEvent", "arg1", 42)

-- Remove a hook
hook.Remove("PlayerSpawn", "my_mod_spawn")

Hooks fire in priority order. Returning false from any hook cancels propagation.

Built-in Hooks

HookArgumentsDescription
ThinkFires every tick (replaces rawframe.on_tick)
PlayerSpawnpeer_idPlayer spawned
PlayerDeathvictim_id, attacker_idPlayer died
PlayerChatpeer_id, messageChat message received
PlayerConnectpeer_idPlayer connected
PlayerDisconnectpeer_idPlayer disconnected

Game State Machine

Manage game flow with states, teams, and spawn points:

-- Set the game state
game.set_state("playing")

-- Teams
team.create(1, "Red", {255, 0, 0})
team.create(2, "Blue", {0, 0, 255})
player.set_team(peer_id, 1)

-- Spawn points
spawn.add("red_spawn", 10, 0, 5, 0)
spawn.add("blue_spawn", -10, 0, 5, 180)

Mod Management API

Query and control mods at runtime:

-- Check mod state
if mod.is_loaded("pvp_core") then
    local info = mod.get_info("pvp_core")
    rawframe.log("Version: " .. info.version)
end

-- Export functions for other mods
mod.export("get_score", function(peer_id)
    return scores[peer_id] or 0
end)

-- Call functions in other mods
local score = mod.call("pvp_core", "get_score", peer_id)

-- Lifecycle callbacks
mod.on_start(function()
    rawframe.log("Mod started!")
end)

mod.on_stop(function()
    rawframe.log("Cleaning up...")
end)

Debugging

Logging

rawframe.log("Debug: player position = " .. tostring(x))

Log output appears in:

  • Server terminal
  • Client console
  • Rawframe Studio console panel

Rawframe Studio Debugger

Rawframe Studio includes a full Luau debugger with:

  • Breakpoints (F9)
  • Step over (F10), step into (F11), step out (Shift+F11)
  • Call stack and variable inspection
  • Script profiler for performance analysis

Hot-Reload

During development, you can hot-reload mods without restarting the server:

  • Files are monitored for changes
  • Modified scripts trigger automatic VM reload
  • In Studio, press Ctrl+Shift+R to force reload

Example: Complete PvP Mod

Here’s a minimal PvP gamemode using multiple engine systems:

-- scripts/server/init.lua
local CONFIG = { max_health = 100, respawn_time = 3.0 }

-- Setup teams
team.create(1, "Red", {255, 0, 0})
team.create(2, "Blue", {0, 0, 255})

-- Handle player spawning
hook.Add("PlayerSpawn", "pvp_spawn", function(peer_id)
    local ent = player.get_entity(peer_id)
    char.set_max_health(ent, CONFIG.max_health)
    char.set_health(ent, CONFIG.max_health)

    -- Assign to team with fewer players
    local red = team.get_players(1)
    local blue = team.get_players(2)
    local t = #red <= #blue and 1 or 2
    player.set_team(peer_id, t)
end)

-- Handle kills
hook.Add("PlayerDeath", "pvp_death", function(victim_id, attacker_id)
    if attacker_id then
        player.broadcast_chat(
            player.get_name(attacker_id) .. " killed " .. player.get_name(victim_id)
        )
    end

    -- Respawn after delay
    timer.delay(CONFIG.respawn_time, function()
        char.respawn(victim_id, 0, 5, 0)
    end)
end)

Next Steps