Skip to content
You're reading the docs for Railroader 0.6 (the MoW Update), currently available on the Experimental branch.

Relay

Relay is Railroader’s scripting language. It’s dynamically typed and designed to be pleasant to read and write, with modern syntax and features like classes, closures, and coroutines. Its syntax is influenced by Swift and C#, while its runtime is influenced by Wren in particular (bytecode VM).

Why create our own scripting language? We weren’t happy with the performance characteristics of other options, particularly when marshalling data between the game and the scripting runtime. Additionally the syntax of some of these options felt burdensome when compared to modern syntax. Finally, security was a consideration. Relay is sandboxed completely within the Railroader game and does not allow filesystem or network access. We also use Relay for our own internal runtime test suite.

For the game scripting API (functions, events, and types available to mods), see the Relay API Reference.

Note that this guide assumes you are familiar with programming concepts like variables, flow control (if/while), classes, functions/methods, and closures. If you’re not, hang in there!

var x = 42 // Integer
var pi = 3.14 // Float
var name = "Alice" // String
var active = true // Bool
var list = [1, 2, 3] // Array
var map = {"key": "value"} // Dictionary (string or integer keys)
var nothing = null // Null

var, let, func, class and enum all bind a name in the enclosing scope, and declaring one name twice in the same scope is a compile error — whichever pair of keywords is involved. Assign to a variable to change it, and use a nested scope to shadow a name:

Introducing a name is always a declaration, so plain = only ever changes something that already exists. That way a misspelled name is reported rather than quietly becoming a second variable:

var speed = 0
speed = 25 // Fine -- 'speed' was declared above
speeed = 25 // Error: 'speeed' doesn't exist yet. Write 'var speeed' to declare it,
// or 'let speeed' if it never changes
var x = 1
var x = 2 // Error: 'x' is already declared in this scope (first declared at line 1)
x = 2 // Fine -- assignment, not a declaration
func g() { }
var g = 2 // Error -- 'func g' already bound 'g' in this scope
if condition {
var x = 3 // Fine -- a different scope; the outer x is untouched
}

Type members are scoped to their type, so a method or property may share a name with something in the enclosing scope.

A class or enum may only be declared at the top level of a script or module — not inside a function, method, closure or block. Declare the type at the top level and pass whatever it needs to its constructor:

func make() {
class A { } // Error: A class can only be declared at the top level of a script or module
}

let declares a constant. It works everywhere var does — top level, function and closure bodies, class and enum bodies, and export — but the initializer is the only value it ever gets, and it must be given one:

let maxCars = 40
maxCars = 50 // Error: Cannot assign to 'maxCars': it is a 'let' constant
maxCars += 1 // Error -- compound assignment is still assignment
let name // Error: 'let' constant 'name' requires an initializer

What is constant is the binding, not the value it refers to. An array or dictionary held by a let can still be changed; you just cannot point the name at a different one:

let cars = ["A", "B"]
cars.add("C") // Fine -- the array itself is not frozen
cars[0] = "Z" // Fine
cars = [] // Error: Cannot assign to 'cars': it is a 'let' constant

In a class, let declares a stored property that is set by its initializer and read-only from then on. static let does the same for a type-level constant. A computed property (one with a { } body) has no storage to protect, so use a getter-only var for that instead:

class Consist {
let maxLength = 40
static let version = 2
var lengthLimit { // read-only, but computed -- 'var' with only a getter
return self.maxLength
}
}
var c = Consist()
print(c.maxLength) // 40
c.maxLength = 50 // Error: Cannot assign to property 'maxLength': it is a 'let' constant
// Interpolation
var msg = "Hello \(name), x = \(x + 1)"
// Multiline
var text = """
Line 1
Line 2
"""
// Operations
str.count, .isEmpty, .contains(), .startsWith(), .endsWith()
.substring(start, length), .replace(old, new), .split(separator)
.trim(), .toLowerCase(), .toUpperCase()

Numbers have instance methods equivalent to some of the math module’s functions — use whichever reads better at the call site:

(-5).abs() // 5
(4.2).floor(), (4.2).ceil(), (4.5).round()
(16).sqrt()
x.isNaN(), x.isFinite(), x.isInteger()
// Arrays
list.count // Length
list.isEmpty // true if count == 0
list.add(item) // Append, returns null
list.insertAt(index, item) // Insert before index, returns null; index == count appends
list.removeAt(index) // Returns the removed element
list.clear() // Remove every element, returns null
list.contains(item)
list.indexOf(item) // -1 if not found
list.first() // First element, or null if empty
list.last() // Last element, or null if empty
list.slice(start, end) // Elements from start up to (not including) end
list.join(separator) // Join elements (as strings) into one string, separator between each
list.asString() // "[1, 2, 3]" - one-line string representation
list.copy() // Shallow copy
list.deepCopy() // Deep copy
// These return a new array and leave the original unchanged.
var asc = list.sorted() // Natural ascending (numbers or strings)
var desc = list.sorted((a, b) => b - a) // Comparator returning <0/0/>0
var byName = list.sortedBy((x) => x.name) // Key selector
var flipped = list.reversed() // Reversed copy
var doubled = list.map((x) => x * 2) // Transform each element
var evens = list.filter((x) => x % 2 == 0) // Keep elements where the closure returns true
// Dictionaries
map.has("key")
map.isEmpty // true if count == 0
map.keys() // List of keys
map.values() // List of values
map.add(key, value) // Set a key, returns null
map.remove("key") // Returns the removed value, or null if the key was absent
map.merge(patch) // Deep-merge patch into map, in place
map.merging(patch) // Deep-merge into a copy, leaving map unchanged
map.copy()
map.deepCopy()

The sorting and transforming methods take a closure, written (x) => expression. See Functions for the full syntax.

Mutating vs. non-mutating: methods named with a plain verb — add, insertAt, remove, removeAt, clear, merge — change the collection in place. Their counterparts ending in -ed/-ing — sorted, sortedBy, reversed, merging — leave the receiver untouched and return a new collection. If a method hands you back a collection, it is always a new one; the in-place methods return null, except remove/removeAt, which return the element they took out.

merging(patch) is exactly deepCopy() followed by merge(patch). Values pulled in from patch are shared with it by reference, so deep-copy the patch first if you intend to mutate the result afterwards.

For both merge and merging, a null value in patch deletes that key; nested dictionaries in patch merge recursively instead of replacing the existing dictionary wholesale. Arrays are replaced wholesale.

0..<10 (exclusive) and 1...5 (inclusive) describe a span of integers. They most often appear as the subject of a for loop, but they are values in their own right — assign one to a variable, pass it around, or inspect it:

var r = 1...5
r.start // 1
r.end // 5
r.inclusive // true
r.isEmpty // false (5..<5 or 10...1 would be true)
r.contains(3) // true
// Arithmetic: +, -, *, /, % -- '/' always gives a float: 1 / 3 is 0.333..., 4 / 2 is 2.0
// -- for a whole number use math.floor(a / b) or (a / b) as Integer
// Assignment: =, +=, -=, *=, /=
// Comparison: ==, !=, <, >, <=, >=
// Logical: &&, || (short-circuit), !
// Null coalescing: ??
// Ternary: condition ? true_val : false_val
// Null-safe
obj?.property // Returns null if obj is null
obj?.method() // Null-safe method call
list?[0] // Null-safe indexing
obj?.a.b.c() // Skips the whole call only if obj itself is null (a/b turning up null mid-chain still throws)
// Conditionals
if x > 0 { print("positive") }
if x > 0 { } else { }
if x > 10 { } else if x > 0 { } else { }
// Loops
while condition { }
for item in list { }
for key in map { }
for i in 0..<10 { } // Exclusive range
for i in 1...5 { } // Inclusive range (ranges only count up, 5...1 is empty)
// Flow control
break, continue, return value

switch matches a subject against one or more case patterns by value equality. Cases do not fall through, so no break is needed. A case body runs from its : up to the next case, default, or the closing } (it is not brace-delimited). List multiple values on one case with commas:

switch day {
case 1, 7:
print("Weekend")
case 2, 3, 4, 5, 6:
print("Weekday")
default:
print("Unknown")
}

A switch may have at most one default clause, and it can appear anywhere in the body (not just last). If no case matches and there is no default, nothing runs.

// Declaration
func add(a, b) {
return a + b
}
// Default parameters
func greet(name, prefix = "Hello") {
return prefix + ", " + name
}
// Arrow functions (single expression)
var multiply = (a, b) => a * b
// Arrow functions (block)
var complex = (x) => {
var result = x * 2
return result + 1
}
// Import native module
import math
var result = math.sqrt(16)
// Import with alias
import math as m
// Export from module
export var counter = 0
export func increment() {
counter += 1
}
// Cross-pack imports (Railroader)
import "domain/module" as mod
from "other/utils" import func1, func2
// Try/catch/finally
try {
riskyOperation()
} catch error {
print("Error: \(error)")
} finally {
cleanup() // Always runs
}
// Throw errors
throw "Something went wrong"
// Protected call
var result = pcall(() => {
return mayFail() // braced closures don't auto-return their last expression
})
if !result[0] {
print("Failed: \(result[1])")
}

catch receives both a value you threw, which arrives unchanged, and an error the runtime raised — a bad index, a failed conversion to a class, a game API call that failed — which arrives as its message string. finally runs even when nothing catches the error.

pcall is the same thing as an expression: it hands back [success, valueOrError] instead of branching to a catch block.

assert(condition) // Raises "Assertion failed"
assert(condition, "message") // Raises a custom message

A failed assert is an error like any other, so try/catch and pcall both catch it.

// Type checking (is)
value is String
value is Integer || value is Float || value is Bool
value is Array || value is Dictionary || value is Closure
obj is MyClass // Includes inheritance
// Type conversion (as)
"42" as Integer // 42
"3.14" as Float // 3.14
value as String // Always succeeds
value as Bool // true unless value is exactly false (0, "", [] are all truthy)
null as Bool // null, not false -- null converts to itself
"text" as Integer ?? 0 // With default
// Array, Dictionary, and Closure conversions only succeed if the value is
// already that type (they never convert between collection types).
value as Array
value as Dictionary
value as Closure

Converting between built-in types returns null on failure, so ?? default is always a safe fallback. Converting to a user-defined class name (value as MyClass) works differently: it succeeds only if value is already an instance of that class or a subclass, and throws a runtime error instead of returning null when it isn’t. Guard it with is or try/catch if value’s type isn’t already known:

if obj is MyClass {
var typed = obj as MyClass // safe, already checked
}
try {
var typed = obj as MyClass // throws if obj is not a MyClass
} catch error {
print("Not a MyClass: \(error)")
}
class Point {
init(x, y) { // Constructor
self.storedX = x
self.storedY = y
self.storedRadius = 0 // Backing fields must be set before first read
}
var x { self.storedX } // Property getter
var magnitude { // Computed property
(self.storedX * self.storedX + self.storedY * self.storedY).sqrt()
}
var radius { // Getter/setter
get { self.storedRadius }
set { if value >= 0 { self.storedRadius = value } }
}
func translate(dx, dy) { // Method
self.storedX += dx
self.storedY += dy
}
}
var p = Point(3, 4) // Instantiate (no 'new')
class Circle is Point {} // Inheritance

Within a method, self refers to the current instance. Every instance property and method must be reached through it — a bare name is always a local or a global, never a member of the surrounding class:

class Car {
var speed = 0
func accelerate() {
speed = speed + 10 // Error: 'speed' is an instance property. Write 'self.speed'.
self.speed = self.speed + 10 // Correct
}
}

This is the same rule Swift and Python follow, and it is what makes a name’s meaning readable without knowing the whole class: if it has no self., it is not a member. It also means a mistyped property is caught rather than quietly becoming a new one.

self is also useful for returning the instance for chaining, or passing it to another function:

class Builder {
init() { self.items = [] }
func add(item) {
self.items.add(item)
return self // Enable chaining
}
}
class Vector {
init(x, y) { self.x = x; self.y = y }
operator + (other) { return Vector(self.x + other.x, self.y + other.y) }
operator - (other) { return Vector(self.x - other.x, self.y - other.y) }
operator * (scalar) { return Vector(self.x * scalar, self.y * scalar) }
operator == (other) { return self.x == other.x && self.y == other.y }
operator [] (index) { return index == 0 ? self.x : self.y }
operator []= (index, value) { if index == 0 { self.x = value } }
func asString() { return "Vector(\(self.x), \(self.y))" }
}
// Supported: +, -, *, /, %, ==, !=, <, <=, >, >=, [], []=
enum Status {
pending, // 0
active = 5, // 5
complete, // 6
failed = "ERROR" // String value
}
var s = Status.active
var name = s.name // "active"
var value = s.value // 5

Enums can also declare methods, computed properties, and operators, the same as classes. Members come first, then the method/property section:

enum Direction {
north, east, south, west
func opposite() {
switch self {
case Direction.north: return Direction.south
case Direction.south: return Direction.north
case Direction.east: return Direction.west
default: return Direction.east
}
}
var isVertical {
self == Direction.north || self == Direction.south
}
}
Direction.north.opposite() // Direction.south
Direction.north.isVertical // true
func countdown(n) {
var i = n
while i >= 1 {
yield i // Suspend and return value
i -= 1
}
return "done"
}

This is a language-level generator, stepped from C# or from test scripts. It’s a different mechanism from the native Coroutine() function, which is what a mod script uses to schedule a closure across multiple frames.

Native modules available to scripts — math, logging, sandboxed JSON loading, and the game API itself. See the Relay API Reference for the full list and how each one is imported.