Skip to main content

Overview

Lich’s scripting engine executes Ruby code in isolated contexts, providing both security and flexibility. Scripts can be trusted or untrusted, with different levels of access to the Ruby environment.

Script Bindings

Script bindings are the execution contexts in which script code runs. Lich uses Ruby’s binding mechanism to create isolated environments.
The binding system is carefully designed to prevent scripts from accessing each other’s local variables while still allowing shared access to game data.

Trusted Scripts

Trusted scripts run with full Ruby access in a trusted binding:
# From lib/common/script.rb:23
TRUSTED_SCRIPT_BINDING = proc { _script }

# Scripts with single label are automatically trusted
if script_obj.labels.length > 1
  trusted = false
else
  trusted = true
end

if trusted
  script_binding = TRUSTED_SCRIPT_BINDING.call
else
  script_binding = Scripting.new.script
end
Trusted scripts (those without labels or with only one label) have unrestricted access to Ruby. Use caution when running untrusted code.

Untrusted Scripts

Untrusted scripts (wizard scripts with multiple labels) run in a restricted context:
class Scripting
  def script
    Proc.new {}.binding
  end
end
This creates a clean binding without access to the outer scope.

Script Types

Standard Scripts (.lic, .rb)

Standard Lich scripts are Ruby files that can use labels for control flow:
# example.lic
echo "Starting script"

loop:
  waitfor "You feel recovered"
  fput "hunt"
  goto 'loop'
Scripts are stored in:
  • scripts/ - Standard scripts
  • scripts/custom/ - User-created scripts (take precedence)

Wizard Scripts (.cmd, .wiz)

Wizard scripts use a legacy syntax that’s automatically converted to Ruby:
# example.cmd
counter set 0

loop:
  counter add 1
  echo Iteration %c
  pause 1
  goto loop
The engine translates wizard syntax at load time:
# From lib/common/script.rb:1066
fixline = proc { |line|
  if line =~ /^([\s\t]*)counter\s+(add|sub|set)\s+([0-9]+)/i
    line = "#{$1}c #{counter_action[$2]}= #{$3}"
  elsif line =~ /^([\s\t]*)echo[\s\t]+(.+)/i
    line = "#{$1}echo #{fixstring.call($2.inspect)}"
  # ... more transformations
}

ExecScripts

ExecScripts allow inline code execution:
# Run code inline
Script.start('exec', 'echo "Hello from exec"')

# Or with a block
ExecScript.start {
  echo "Running inline code"
  5.times { |i| echo "Count: #{i}" }
}
ExecScripts always run as trusted code:
# From lib/common/script.rb:876
script_binding = TRUSTED_SCRIPT_BINDING.call
eval('script = Script.current', script_binding, script.name.to_s)
eval(cmd_data, script_binding, script.name.to_s)

Script Lifecycle

1

Script Creation

Script file is loaded and parsed, labels are extracted
2

Binding Assignment

Trusted or untrusted binding is chosen based on script structure
3

Thread Creation

A new thread is spawned with the script binding
4

Execution

Script code runs with access to Script.current and game data
5

Cleanup

At-exit handlers run, thread is cleaned up

Starting Scripts

# From lib/common/script.rb:346
def Script.start(*args)
  @@elevated_script_start.call(args)
end

def Script.run(*args)
  if (s = @@elevated_script_start.call(args))
    sleep 0.1 while @@running.include?(s)
  end
end
Example usage:
# Start a script (non-blocking)
Script.start('hunting', 'trolls')

# Run a script (blocking until complete)
Script.run('go2', 'town square')

# Force start even if already running
Script.start('myscript', '', force: true)

Script Registry

All running scripts are tracked:
@@running = Array.new  # All script instances

def Script.running
  list = Array.new
  for script in @@running
    list.push(script) unless script.hidden
  end
  return list
end

def Script.hidden
  list = Array.new
  for script in @@running
    list.push(script) if script.hidden
  end
  return list
end

Script Context

Every script has access to:

Script Object

script = Script.current

# Script metadata
echo script.name           # Script name
echo script.vars           # Command-line arguments
echo script.vars[0]        # First argument

# Script control
script.pause               # Pause execution
script.unpause             # Resume execution
script.kill                # Stop script

Variables

# Access script arguments
script.vars[0]    # All args as single string
script.vars[1]    # First argument
script.vars[2]    # Second argument
# etc.

Script-Specific Storage

# Database access
db = Script.db
db.execute("CREATE TABLE IF NOT EXISTS data (key TEXT, value TEXT)")

# File access
Script.open_file('txt', 'w') do |f|
  f.puts "Script data"
end

# Logging
Script.log "Important event occurred"

Error Handling

The engine catches and reports errors:
# From lib/common/script.rb:112
begin
  eval(script.labels[script.current_label].to_s, 
       script_binding, script.name)
rescue SystemExit
  nil
rescue SyntaxError
  respond "--- Lich: error: #{$!}\n\t#{$!.backtrace[0..1].join("\n\t")}"
  Lich.log "error: #{$!}\n\t#{$!.backtrace.join("\n\t")}"
rescue StandardError
  respond "--- Lich: error: #{$!}\n\t#{$!.backtrace[0..1].join("\n\t")}"
  Lich.log "error: #{$!}\n\t#{$!.backtrace.join("\n\t")}"
ensure
  Script.current.kill
end

At-Exit Handlers

Scripts can register cleanup code:
Script.at_exit {
  echo "Script is ending, cleaning up..."
  # Close files, save state, etc.
}

# Clear all at-exit handlers
Script.clear_exit_procs

# Exit without running handlers
Script.exit!
At-exit handlers run in the script’s thread context. If the thread is forcibly killed, handlers may not execute.

Thread Groups

Scripts can spawn helper threads:
new_thread = Thread.new {
  100.times { |i|
    echo "Helper thread: #{i}"
    sleep 1
  }
}

# Add to script's thread group for automatic cleanup
Script.current.thread_group.add(new_thread)
When the script ends, all threads in its thread group are cleaned up.

Script Communication

Starting Other Scripts

# Start another script
Script.start('helper', 'arg1 arg2')

# Wait for another script to finish
wait_while { Script.running?('helper') }

Shared Data

Scripts can share data through:
  1. SharedVariables - Thread-safe shared storage
  2. Script.running - Query other running scripts
  3. Game state - XMLData, GameObj are shared
# Use UserVars for simple shared state
UserVars.my_flag = true

# Other scripts can read
if UserVars.my_flag
  echo "Flag is set!"
end

Security Considerations

Modern Ruby (2.3+) doesn’t enforce $SAFE levels, so the trust system is primarily organizational. Untrusted scripts still have full Ruby access.
Scripts cannot access each other’s local variables, but can access shared game state and Ruby globals.
Scripts can read/write files through Script.open_file, which restricts paths to the data directory.

Performance Tips

  1. Minimize blocking operations - Use waitfor instead of tight loops
  2. Cache expensive calculations - Store results in script variables
  3. Use helper threads sparingly - Each thread has overhead
  4. Clean up properly - Use at-exit handlers to free resources

Example: Complete Script

# hunting.lic
# A complete script demonstrating key concepts

echo "Starting hunting script"
echo "Target: #{script.vars[1]}"

# At-exit handler for cleanup
Script.at_exit {
  echo "Ending hunting session"
}

# Main loop with label
hunt_loop:
  # Wait for roundtime
  waitrt?
  
  # Look for targets
  targets = GameObj.npcs.select { |npc| 
    npc.noun == script.vars[1] && npc.status != 'dead'
  }
  
  if targets.empty?
    echo "No targets found, searching..."
    fput "search"
    pause 5
    goto 'hunt_loop'
  end
  
  # Attack first target
  target = targets.first
  fput "attack ##{target.id}"
  
  # Wait for combat to end
  waitfor 'You feel recovered'
  
  goto 'hunt_loop'

Next Steps

XML Parsing

Learn how game data is extracted

Game Objects

Work with NPCs, items, and inventory

Build docs developers (and LLMs) love