Skip to content

Tutorial: Optimization — Optimizer & Decorators ​

🟡 Intermediate⏱️ 20 min

Prerequisites: Hello World, Game Mechanics

Why Optimization Matters ​

A Minecraft server runs at 20 ticks per second. Every function in your datapack that runs on a tick adds latency. A single poorly written @tick can lag a server with dozens of players.

RedScript's optimizer automatically eliminates dead code, folds constants, and simplifies loops — but it works best when you write code with performance in mind and apply the right decorators.

This tutorial teaches:

  • Understanding the fixed optimization pipeline
  • @tick(rate=N) — spreading work over time
  • @inline — eliminating small function call overhead
  • @keep — preventing DCE from removing needed functions
  • @coroutine — handling expensive work without freezing the server
  • Using snapshot flags to inspect compile stages

Step 1: Optimizer defaults (no CLI levels) ​

Current RedScript does not expose public -O0 / -O1 / -O2 levels. The optimizer runs a fixed safe pipeline every time you compile.

Use snapshot flags when you want to inspect optimizer-stage boundaries:

bash
# Snapshot selected stages for analysis
redscript compile src/main.mcrs \
  --snapshot-stages parse,typecheck,runtimeAssets,emitDatapack \
  --snapshot-output .redscript/stages.json

# Snapshot all stages
redscript compile src/main.mcrs \
  --snapshot-stages all --snapshot-output .redscript/stages.json

--stats is not a compile option in current CLI output.


Step 2: Rate-Limited Ticks ​

The single biggest performance win in most datapacks is reducing how often tick functions run.

❌ Bad — runs 20×/second:

rs
@tick
fn update_sidebar() {
    foreach (p in @a) {
        let kills: int = scoreboard_get(p, "kills")
        actionbar(p, f"Kills: {kills}")
    }
}

✅ Good — runs once per second:

rs
@tick(rate=20)
fn update_sidebar() {
    foreach (p in @a) {
        let kills: int = scoreboard_get(p, "kills")
        actionbar(p, f"Kills: {kills}")
    }
}

✅ Better for slow updates — runs every 5 seconds:

rs
@tick(rate=100)
fn update_leaderboard() {
    // Expensive per-player calculation
    foreach (p in @a) {
        let k: int = scoreboard_get(p, "kills")
        let d: int = scoreboard_get(p, "deaths")
        if (d > 0) {
            scoreboard_set(p, "kd", k * 100 / d)
        }
    }
}

Rule of thumb:

Update frequency neededRate
Every tick (reaction-critical)@tick
Visual smoothness (HUD)@tick(rate=2)
Stat updates@tick(rate=20) – @tick(rate=100)
Leaderboard, announcements@tick(rate=200) – @tick(rate=1200)

Step 3: @inline for Hot Helpers ​

Small helper functions called many times per tick are good candidates for inlining. The optimizer substitutes the body directly at the call site, removing the function call overhead.

rs
// Without @inline: a function call is generated every time clamp() is used
fn clamp(val: int, lo: int, hi: int) -> int {
    if (val < lo) { return lo }
    if (val > hi) { return hi }
    return val
}

// With @inline: the body is pasted in at each call site
@inline
fn clamp(val: int, lo: int, hi: int) -> int {
    if (val < lo) { return lo }
    if (val > hi) { return hi }
    return val
}

Usage is identical — only the compiled output changes:

rs
@tick(rate=1)
fn clamp_health() {
    foreach (p in @a) {
        let hp: int = scoreboard_get(p, "hp")
        let clamped: int = clamp(hp, 0, 200)   // inlined: no function call
        scoreboard_set(p, "hp", clamped)
    }
}

When to use @inline:

  • Functions under ~5 lines
  • Called inside loops or per-player loops
  • Pure math/logic helpers

When NOT to use @inline:

  • Large functions (code bloat)
  • Recursive functions (unsupported)
  • Functions you want to call manually with /function

Step 4: Constant Folding in Practice ​

Decorator rates currently require integer literals. Use named constants in ordinary code; the examples below spell interval values explicitly.

The optimizer evaluates constant expressions at compile time. Write configuration as named constants and let the compiler crunch them.

rs
// These are all folded at compile time — zero runtime cost
const TICKS_PER_SECOND: int  = 20
const TICKS_PER_MINUTE: int  = 20 * 60       // → 1200
const GRACE_PERIOD:     int  = 3 * 20        // → 60 ticks = 3 seconds
const MAX_STREAK_BONUS: int  = 5 * 10        // → 50

@tick(rate=1200)
fn hourly_announcement() {
    say("Remember to vote for the server!")
}

@on(PlayerJoin)
fn grace_period(player: Player) {
    // Give new players 3 seconds of invulnerability
    effect(player, "minecraft:resistance", GRACE_PERIOD, 255)
}

The compiled datapack uses 1200, 60, and 50 directly — no arithmetic at runtime.


Step 5: @keep — Preventing Unwanted DCE ​

Dead code elimination (DCE) removes functions not reachable from any entrypoint. Sometimes you want a function available for external /function calls without wiring it into the normal event flow.

rs
// This function is never called from RedScript code,
// but we want `/function killboard:debug_dump` to work in-game.
@keep
fn _debug_dump() {
    foreach (p in @a) {
        let k: int = scoreboard_get(p, "kills")
        let d: int = scoreboard_get(p, "deaths")
        tell(p, f"kills={k} deaths={d}")
    }
}

Without @keep, DCE would remove _debug_dump because nothing calls it. With @keep, it is always emitted.


Manual Experimental Option: --experimental-lir-local-copy-rewrite ​

This flag is available for explicit optimization experimentation only:

bash
redscript compile src/main.mcrs --experimental-lir-local-copy-rewrite

It enables an additional LIR local-copy rewrite pass and is intentionally off by default. Current reports describe this as benchmarked evidence that should be treated as manual/experimental; it is not a general production default.


Step 6: @coroutine for Expensive Work ​

Some operations need to touch many entities or run complex calculations. Doing them all in one tick stalls the server. Use @coroutine to spread the work across multiple ticks automatically.

rs
// Without @coroutine: this loop blocks one tick entirely
fn scan_all_entities() {
    let i: int = 0
    while (i < 500) {
        // process entity i
        i = i + 1
    }
}

// With @coroutine: the compiler inserts yield points every `batch` iterations
@coroutine(batch=50, onDone="scan_complete")
fn scan_all_entities() {
    let i: int = 0
    while (i < 500) {
        // process entity i
        i = i + 1
    }
}

fn scan_complete() {
    say("Entity scan finished across 10 ticks!")
}

With batch=50, the compiler splits the 500-iteration loop into 10 resumable chunks of 50 iterations. Each chunk runs on a separate tick, and scan_complete fires when all chunks are done.


Putting It All Together ​

Here's an optimized version of the kill leaderboard from Tutorial 02:

rs
namespace killboard_optimized

// ─── Constants ──────────────────────────────────────────────

const SIDEBAR_RATE:     int = 20     // update sidebar every second
const LEADERBOARD_RATE: int = 200    // update K/D every 10 seconds
const GRACE_TICKS:      int = 3 * 20 // 3-second spawn protection

// ─── Init ───────────────────────────────────────────────────

@load
fn init() {
    scoreboard_add_objective("kills",      "playerKillCount")
    scoreboard_add_objective("deaths",     "deathCount")
    scoreboard_add_objective("streak",     "dummy")
    scoreboard_add_objective("kd",         "dummy")
    scoreboard_add_objective("prev_kills", "dummy")
    scoreboard_display("sidebar", "kills")
    say("Kill Leaderboard (optimized) loaded!")
}

// ─── Inlined Helpers ────────────────────────────────────────

@inline
fn safe_kd(k: int, d: int) -> int {
    if (d == 0) { return k * 100 }
    return k * 100 / d
}

// ─── Rate-Limited Ticks ─────────────────────────────────────

@tick(rate=20)
fn update_sidebar() {
    foreach (p in @a) {
        let kills: int = scoreboard_get(p, "kills")
        actionbar(p, f"Kills: {kills}")
    }
}

@tick(rate=200)
fn update_kd() {
    foreach (p in @a) {
        let k: int = scoreboard_get(p, "kills")
        let d: int = scoreboard_get(p, "deaths")
        scoreboard_set(p, "kd", safe_kd(k, d))
    }
}

// ─── Events ─────────────────────────────────────────────────

@on(PlayerJoin)
fn on_join(player: Player) {
    effect(player, "minecraft:resistance", GRACE_TICKS, 255)
    tell(player, "⚔️ Welcome! You have 3 seconds of spawn protection.")
}

@on(PlayerDeath)
fn on_death(player: Player) {
    let streak: int = scoreboard_get(player, "streak")
    if (streak >= 5) {
        say(f"🔥 {player} ended with a {streak} kill streak!")
    }
    scoreboard_set(player, "streak", 0)
}

// ─── Debug (kept for manual /function use) ──────────────────

@keep
fn _debug_stats() {
    foreach (p in @a) {
        let k:  int = scoreboard_get(p, "kills")
        let d:  int = scoreboard_get(p, "deaths")
        let kd: int = scoreboard_get(p, "kd")
        tell(p, f"kills={k} deaths={d} kd={kd / 100}.{kd % 100}")
    }
}

Build and read output ​

bash
redscript compile src/main.mcrs -o out

Typical output:

✓ Compiled src/main.mcrs to out/
  Namespace:  killboard
  Files:     9

If you need machine-readable stage boundaries, pass --snapshot-stages and --snapshot-output as shown in Step 1.


Summary ​

TechniqueWhen to use
Compile defaultsAlways use redscript compile for the fixed default optimizer
@tick(rate=N)Any periodic function that does not need every-tick precision
@inlineSmall pure helpers called in loops
@keepFunctions you call via /function externally
@coroutineLoops over hundreds of entities or expensive multi-step calculations
--experimental-lir-local-copy-rewriteManual optimization experiments, with evidence-focused validation
ConstantsAny magic numbers used in multiple places

What's Next? ​

Released under the MIT License.