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!
Values & Types
Section titled “Values & Types”Variables & Types
Section titled “Variables & Types”var x = 42 // Integervar pi = 3.14 // Floatvar name = "Alice" // Stringvar active = true // Boolvar list = [1, 2, 3] // Arrayvar map = {"key": "value"} // Dictionary (string or integer keys)var nothing = null // Nullvar, 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 = 0speed = 25 // Fine -- 'speed' was declared abovespeeed = 25 // Error: 'speeed' doesn't exist yet. Write 'var speeed' to declare it, // or 'let speeed' if it never changesvar x = 1var 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}Constants
Section titled “Constants”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 = 40maxCars = 50 // Error: Cannot assign to 'maxCars': it is a 'let' constantmaxCars += 1 // Error -- compound assignment is still assignment
let name // Error: 'let' constant 'name' requires an initializerWhat 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 frozencars[0] = "Z" // Finecars = [] // Error: Cannot assign to 'cars': it is a 'let' constantIn 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) // 40c.maxLength = 50 // Error: Cannot assign to property 'maxLength': it is a 'let' constantStrings
Section titled “Strings”// Interpolationvar msg = "Hello \(name), x = \(x + 1)"
// Multilinevar text = """ Line 1 Line 2 """
// Operationsstr.count, .isEmpty, .contains(), .startsWith(), .endsWith().substring(start, length), .replace(old, new), .split(separator).trim(), .toLowerCase(), .toUpperCase()Numbers
Section titled “Numbers”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()Collections
Section titled “Collections”// Arrayslist.count // Lengthlist.isEmpty // true if count == 0list.add(item) // Append, returns nulllist.insertAt(index, item) // Insert before index, returns null; index == count appendslist.removeAt(index) // Returns the removed elementlist.clear() // Remove every element, returns nulllist.contains(item)list.indexOf(item) // -1 if not foundlist.first() // First element, or null if emptylist.last() // Last element, or null if emptylist.slice(start, end) // Elements from start up to (not including) endlist.join(separator) // Join elements (as strings) into one string, separator between eachlist.asString() // "[1, 2, 3]" - one-line string representationlist.copy() // Shallow copylist.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/>0var byName = list.sortedBy((x) => x.name) // Key selectorvar flipped = list.reversed() // Reversed copyvar doubled = list.map((x) => x * 2) // Transform each elementvar evens = list.filter((x) => x % 2 == 0) // Keep elements where the closure returns true
// Dictionariesmap.has("key")map.isEmpty // true if count == 0map.keys() // List of keysmap.values() // List of valuesmap.add(key, value) // Set a key, returns nullmap.remove("key") // Returns the removed value, or null if the key was absentmap.merge(patch) // Deep-merge patch into map, in placemap.merging(patch) // Deep-merge into a copy, leaving map unchangedmap.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.
Ranges
Section titled “Ranges”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...5r.start // 1r.end // 5r.inclusive // truer.isEmpty // false (5..<5 or 10...1 would be true)r.contains(3) // trueOperators & Control Flow
Section titled “Operators & Control Flow”Operators
Section titled “Operators”// 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-safeobj?.property // Returns null if obj is nullobj?.method() // Null-safe method calllist?[0] // Null-safe indexingobj?.a.b.c() // Skips the whole call only if obj itself is null (a/b turning up null mid-chain still throws)Control Flow
Section titled “Control Flow”// Conditionalsif x > 0 { print("positive") }if x > 0 { } else { }if x > 10 { } else if x > 0 { } else { }
// Loopswhile condition { }for item in list { }for key in map { }for i in 0..<10 { } // Exclusive rangefor i in 1...5 { } // Inclusive range (ranges only count up, 5...1 is empty)
// Flow controlbreak, continue, return valueswitch 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.
Functions & Modules
Section titled “Functions & Modules”Functions
Section titled “Functions”// Declarationfunc add(a, b) { return a + b}
// Default parametersfunc 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}Modules
Section titled “Modules”// Import native moduleimport mathvar result = math.sqrt(16)
// Import with aliasimport math as m
// Export from moduleexport var counter = 0export func increment() { counter += 1}
// Cross-pack imports (Railroader)import "domain/module" as modfrom "other/utils" import func1, func2Error Handling
Section titled “Error Handling”// Try/catch/finallytry { riskyOperation()} catch error { print("Error: \(error)")} finally { cleanup() // Always runs}
// Throw errorsthrow "Something went wrong"
// Protected callvar 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.
Assertion
Section titled “Assertion”assert(condition) // Raises "Assertion failed"assert(condition, "message") // Raises a custom messageA failed assert is an error like any other, so try/catch and pcall both catch it.
Type Checking & Conversion
Section titled “Type Checking & Conversion”// Type checking (is)value is Stringvalue is Integer || value is Float || value is Boolvalue is Array || value is Dictionary || value is Closureobj is MyClass // Includes inheritance
// Type conversion (as)"42" as Integer // 42"3.14" as Float // 3.14value as String // Always succeedsvalue 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 Arrayvalue as Dictionaryvalue as ClosureConverting 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)")}Classes & Enums
Section titled “Classes & Enums”Classes
Section titled “Classes”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 {} // InheritanceWithin 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 }}Operator Overloading
Section titled “Operator Overloading”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.activevar name = s.name // "active"var value = s.value // 5Enums 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.southDirection.north.isVertical // trueGenerators/Coroutines
Section titled “Generators/Coroutines”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.
Standard Library
Section titled “Standard Library”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.
© 2026 Giraffe Lab LLC. All rights reserved.