Overview

GameLisp's documentation is split into three parts:

  • The Reference Manual, which you're currently reading, is a rough overview of the language. It's written in an accessible style, so it doubles as a tutorial when read from beginning to end.

  • The standard library documentation describes all of the special forms, functions and macros which are built into the language itself. It covers most of the same material as Section 1 of the Reference Manual, but it's more formal, comprehensive and precise.

  • The glsp crate documentation describes how to embed GameLisp into a Rust program. Section 2 of the Reference Manual will walk you through the basics.

Getting Started

If you haven't already, take a look at the Introduction for Rust Programmers and/or the Introduction for Lisp Programmers.

You can get a feel for the language by examining the source code for a few small games on the interactive playground.

To start setting up a GameLisp project of your own, check that you're running the latest version of nightly Rust, and then add this line to your Cargo.toml:

[dependencies]
glsp = "0.1"

The following boilerplate code will load and run a single GameLisp source file named main.glsp in the working directory, printing a stack trace if any errors occur. (The working directory is usually the same directory which contains your project's Cargo.toml file.)

use glsp::prelude::*;

fn main() {
	let runtime = Runtime::new();
	runtime.run(|| {
		glsp::load("main.glsp")?;
		Ok(())
	});
}

Once that's up and running, I'd recommend working your way through the Reference Manual from start to finish. It's not too long - it can be completed in three hours or so. As you progress, you can use your skeleton project to experiment with the language, the standard library, and the Rust API.

If you use Sublime Text, a basic syntax-highlighting scheme for GameLisp can be downloaded from the GitHub repository.

If you've never worked with Lisp before, you might find some parts of GameLisp difficult to understand. If you're struggling, consider looking through some beginner-level material for another Lisp dialect, such as the excellent Practical Common Lisp by Peter Seibel.

⚠️ Stability Warning ⚠️

Because GameLisp is brand new (SemVer 0.1), it currently provides no stability guarantees. The language, standard library and Rust API can and will change without notice. It's also immature enough that you're likely to come across the occasional bug - if so, a bug report would be appreciated.

GameLisp can still be used for serious game projects (I'm using it for one myself!), but you'll need to be prepared to refactor your codebase from time to time.

Introduction for Rust Programmers

Rust is my favourite programming language. It has impeccable performance, an expressive and powerful type system, and a great community.

However, when it comes to game development, Rust has a few well-known problems. Compile times are painfully slow, and the type system can sometimes feel rigid or bureaucratic. When adding a new feature to a game, you'll often need to write code in a messy, fast, exploratory fashion - and in those cases, Rust can be a hindrance rather than a help.

GameLisp is a scripting language for Rust game development, designed while creating The Castle on Fire. It complements Rust by being very different from it: the Yin to Rust's Yang. Unlike Rust, it's an interpreted, dynamically-typed language, comparable to Lua, Python or Ruby. When using GameLisp, your project can be rebuilt in milliseconds rather than minutes, and there's no static type-checker to step on your toes.

Of course, you could already get those benefits by binding an existing scripting language to your Rust game project, using a crate like rlua for Lua or pyo3 for Python - so why add a new language into the mix? GameLisp has a few features which I think make it a better choice:

  • GameLisp is a "Rust-first" scripting language. Integration of GameLisp code into a Rust project is low-friction, high-performance, and completely memory-safe. Installation and distribution are trivial (it's just a crate!). Language features like match, for and struct are designed to closely resemble their Rust counterparts.

  • Garbage-collection pauses lead to dropped frames, and dropped frames lead to unhappy players. GameLisp has an unusual garbage collector which is designed to be called once per frame, every frame, spreading out the workload so that it takes up a consistent amount of runtime. In a real-world game codebase, GameLisp's garbage collector only takes up 0.1 milliseconds of frametime (that is, only 0.6% of the available time budget).

    • More generally, microbenchmarks suggest that GameLisp's performance currently hovers somewhere between interpreted Lua and interpreted Python.
  • As a Lisp dialect, GameLisp is extremely customizable. Its macro system allows you to define new syntax which is indistinguishable from the built-in syntax; create domain-specific languages; or even customize fundamental language features like class and =.

    • If you've used Lisp before and found that it wasn't to your taste, note that GameLisp avoids a few common pain points from other Lisp dialects.
  • GameLisp follows the Arc philosophy of being concise, easy to type and easy to edit. The most common types and functions tend to be only a few characters in length: str, obj?, =, defn, rev. It includes several language features intended to reduce boilerplate, such as iterators, coroutines, pattern-matching, and (of course) macros. You could expect a thirty-line function in a Lua project to be a fraction of that length in a GameLisp project.

  • GameLisp has a novel object system, inspired by Andy Gavin's writings on GOOL, which is uniquely well-suited for game development. All objects are state machines: in the same way that your Rust codebase might use an enum or an Option to statically guarantee that its data model is never invalid, GameLisp code can use a state form to explicitly model the life cycle of its in-game entities. GameLisp's object model avoids inheritance (OOP's biggest mistake!); instead, it encourages composition via classmacros and mixins.

If any of the above has whet your appetite, take a look at Getting Started for more information.

If you have previous experience with Lisp, you might be interested in the Introduction for Lisp Programmers.

Introduction for Lisp Programmers

Veteran Lisp programmers will find some parts of GameLisp's design surprising. I've done my best to preserve Lisp's strengths, but I haven't hesitated to remove or modify features which are standard in other Lisp dialects.

The most Lispy features of GameLisp are its homoiconicity, its Common‑Lisp‑style macro system, and the powerful metaprogramming which that implies. The parser, printer, symbols, and data‑as‑code should feel familiar to any Lisp programmer.

The least Lispy feature of GameLisp is the fact that it's not a functional language. Game code tends to be object-oriented and imperative, so GameLisp is an object-oriented, imperative language. Like Rust, it sprinkles in a little bit of functional flavour here and there (such as its Rust-style iterator adapters and pattern-matching) - but unlike Rust, most data is mutable-by-default and there are no restrictions on shared ownership.

Because it's not a functional language, GameLisp doesn't use lists. The singly-linked-list data structure loses many of its advantages when coding in a non-functional style, while still having many disadvantages (caddar, poor big-O complexity for many operations, extra pressure on the allocator...), so I've completely removed cons cells from the language. Instead, (a b c) represents a double-ended contiguous array (specifically, a VecDeque). I've found that for representing syntax, and as a general-purpose sequential data structure, double-ended arrays are significantly more versatile and convenient than lists. The slightly increased cost of (rest x) hasn't been a problem in practice.

(I'm aware that Lisp is an acronym for List Processing, and I do appreciate the irony...)

Finally, I've made GameLisp's syntax and keywords as "Rust-like" as possible, to minimize the mental friction when switching between the Rust and GameLisp parts of your codebase. I've also changed a handful of names to make them more concise or more descriptive. Notable changes:

  • lambda has been renamed to fn.

  • set!/setf has been renamed to =.

    • The numeric-equality test = has been renamed to ==.
    • The naming convention for assignment, set-something!, is now something=.
    • I've retained the ! suffix for other mutating functions, like push! and inc!, and the ? suffix for predicates, like int?.
  • progn/begin has been renamed to do.

  • The special values for true, false and nil are written as #t, #f and #n. Nil, false, and the empty sequence () are all distinct from one another.

  • let no longer defines a block: it introduces a new binding which is scoped to the end of its enclosing block, similar to define in Scheme or let in Rust.

  • Conditionals follow the conventions from Racket: prefer cond or match rather than if, unless it's a very brief conditional which fits into a single line.

  • Sequence-processing primitives have been renamed to match their Rust equivalents: len rather than length, rev rather than reverse, and so on.

  • A small number of new abbreviations (all of which have a straightforward representation):

    • [a b] to access the field b in the collection a.
    • @name to access the field name on a method's self-object.
    • (.meth-name a b c) to invoke the method meth-name on the object a.
    • (callee ..args x) to splay the contents of the args collection into individual function arguments. This is similar to (apply), but more powerful and versatile.
  • The quasiquote syntax resembles Clojure. Forms are unquoted with ~ rather than ,, and symbols can be auto-gensymed with a # suffix, as in name#. We parse commas as whitespace so that function or macro arguments can be visually grouped together, like (let a b, c d).

Early on in GameLisp's development, its syntax and keywords were much more Scheme-like. These changes aren't arbitrary - each of them came from battle-testing GameLisp while developing The Castle on Fire. They each represent something which has caused me enough frustration that I thought it would be worth refactoring a dense, 15,000-line codebase to change it.

That being said, GameLisp is still in its infancy. If you're interested in shaping the growth of a new Lisp - specifically, if you've spent a little time working with GameLisp and you have a suggestion for its syntax, naming conventions or style conventions - please feel free to get in touch on GitHub. Some changes which are possible now might be impossible in twelve months' time, so don't hesitate!

The Language

This section begins with three in-depth chapters describing...

  • The parser, which converts GameLisp source text into data.
  • The expander, which takes GameLisp data and performs transformations on it.
  • The evaluator, which takes expanded GameLisp data and executes it as a program.

The rest of this section then takes a more casual tour through the GameLisp Standard Library, introducing the most important built-in functions, macros and special forms.

After finishing this section, you should feel confident writing basic GameLisp code, referring back to the first three chapters and the standard library documentaton when necessary.

Syntax and Types

Here is a toy GameLisp program which calculates the first thirty Fibonacci numbers:

(let fibs (arr 0 1))

(while (< (len fibs) 31)
  (push! fibs (+ [fibs -1] [fibs -2])))

(prn [fibs -1]) ; prints 832040

In this chapter, we will describe GameLisp's syntax in full; explain why it's so different from the syntax of languages like Rust; and start to think about the unique advantages which come from those differences.

Homoiconicity

All programming languages have something called an abstract syntax tree (AST): a data representation of a program's source code. The AST is usually hidden inside the compiler, where the programmer can't interact with it directly.

For example, this fragment of Rust source code...


#![allow(unused_variables)]
fn main() {
foo.bar(1)
}

...could be represented by this (imaginary) AST data structure:


#![allow(unused_variables)]
fn main() {
Expression::MethodCall {
	callee: Identifier("foo"),
	method_name: Identifier("bar"),
	arguments: vec![
		Expression::Literal(Literal::Int(1))
	]
}
}

You've probably also encountered text-based data interchange formats, like JSON or TOML. They provide a way for us to store common data structures, like arrays, hash tables, booleans and strings, in a human-readable file format which is easy to edit.

For example, this fragment of JSON describes an array which contains each of the integers from 0 to 4 inclusive:

[0, 1, 2, 3, 4]

GameLisp, like all Lisp languages, is homoiconic. This means that its AST is made out of simple, primitive data types like arrays and booleans - the same data types which you might encounter in a JSON file. Let's look more closely at the first line of our Fibonacci program:

(let fibs (arr 0 1))

This is just the text representation of an array with these three elements:

  • The symbol let
  • The symbol fibs
  • A nested array, with these three elements:
    • The symbol arr
    • The integer 0
    • The integer 1

A comparable JSON fragment would be:

["let", "fibs", ["arr", 0, 1]]

In other words, in GameLisp, code is data and data is code. A GameLisp program is not defined by a block of text - instead, it's defined by a tree of simple data-types. That tree could have been read from a text file, but it could just as well have been generated from nothing by a GameLisp program, or deserialized from a binary file using Serde, or any number of other possibilities.

A flowchart showing the generation of ASTs in Rust and GameLisp

Expansion

There's no sense in denying it: building programs out of array literals causes them to have a relatively ugly syntax. Why is GameLisp designed this way?

The big advantage is that, when code is data, it becomes much easier to write code which creates or modifies other code on your behalf. There's no need to mess around with an AST or manipulate the raw text of your program - it's all just arrays!

For example, here's a GameLisp program which reads in a GameLisp source file, performs gettext‑style localization on it (replacing English string literals with French string literals based on a one-to-one mapping listed in a file), and then runs the program:

(let replacements (parse-1 (read-file "en-to-fr.glsp")))
(let source (parse-all (read-file "source.glsp")))

(let-fn recursively-translate (val)
  (match val
    (coll : (or arr? tab?)
      (map-syntax recursively-translate coll))
    (st : str?
      (or [replacements (? st)] st))
    (val
      val)))

(eval-multi (recursively-translate source))

To make this kind of code-generation more convenient, all GameLisp code is automatically put through an "expansion pass" just before being run. The expansion pass inspects each array in the program; checks whether that array's first element is the name of a macro (a function which transforms GameLisp source code into different GameLisp source code); and if so, uses that macro to transform the array into something else.

Unlike Rust macros, GameLisp macros are indistinguishable from the language's built-in syntax. This gives you the ability to profoundly customise the language - we'll explore some of the possibilities in the Macros chapter.

Representable Types

GameLisp is a dynamically-typed language. Memory locations like global variables, table fields and function arguments always store a single generic "value", which can belong to any of sixteen different primitive types. The full set of types is summarized below.

Of these sixteen types, nine of them can be losslessly converted into text and then read back in by the parser. We say that they are "representable", because they have an exact text representation. Valid GameLisp source code is entirely made out of these representable types.

nil

nil is the type of a value which is absent, uninitialized or undefined. In Rust terms, it takes on the roles of both None and ().

It only has one value, which is represented as #n.

bool

bool is the type of a boolean value: either true (represented as #t) or false (represented as #f).

When testing whether or not a value is true (for example, when deciding how an if expression should branch), we consider #f and #n to be "falsy" values, and all other values to be "truthy".

int, flo

The int type is a 32-bit signed integer number: an i32.

The flo type is a 32-bit floating-point number: an f32.

The text representation for numbers is almost identical to their representation in Rust. You can embed underscores in numbers, and integers can be prefixed with 0b, 0o or 0x to change their base.

There are only a few small changes compared to Rust's syntax:

  • Type suffixes, like _i8 or _f32, are forbidden.
  • Infinities are represented as +inf.0 and -inf.0.
  • "Not a number" is represented as nan.0.

We'll discuss these types in more detail in the Numbers chapter.

sym

The sym type is a symbol, also known as an interned string.

GameLisp uses symbols to represent identifiers and keywords in an efficient way. In the declaration (let var-name 0), both let and var-name are symbols. Representing them as symbols rather than strings makes several basic operations much faster: comparing one identifier to another, looking up a global variable, parsing a keyword from a file, and so on.

A symbol's name is a sequence of one or more of the following characters: a to z, A to Z, 0 to 9, !, $, %, &, *, +, -, ., /, :, <, =, >, ?, ^, ~ or _. A symbol can optionally be suffixed with a single #.

This opens up a much richer selection of identifiers, compared to Rust. Any of the following symbols would be valid names for a variable:

push!
int?
name=
foo:bar
-
//
1+
&$+%~#

When the parser encounters a sequence of characters which could represent a symbol, or could represent a number or abbreviation, then the number or abbreviation will take priority. This means that not all symbols are representable. The symbol -10 can exist, but if you write it to a file, it will be read back in as a number instead.

char

The char type is a character. Its storage is identical to a Rust char.

Characters are represented by a backslash followed by the character itself - for example, \a, \(, or \🦀.

Certain whitespace characters are represented by a backslash followed by their name: \space, \tab, \newline, \return, and \nul.

You can represent an arbitrary unicode character as either...

  • \xNN, where NN is a hexadecimal value from 00 to 7f inclusive.
  • \u{NN}, where the curly braces may enclose anywhere from one to six hexadecimal digits.

str

The str type is a text string.

The representation of strings is identical to a Rust string literal, including all of the same escape sequences. Raw strings, like r##"text"##, are supported.

Strings, characters and symbols are all discussed together in the Strings and Text chapter.

arr

The arr type is an array of arbitrary values. More specifically, it's a double-ended queue, similar to VecDeque in Rust.

An array is represented by the open-parenthesis (, followed by the representation of zero or more values, followed by the close-parenthesis ).

For example, an array containing squares of the first five positive integers would be represented as (1 4 9 16 25). An empty array would be represented as ().

The Arrays chapter has more details.

tab

The tab type is a table (specifically, a HashMap) which maps values to values.

A table is represented by #(, followed by zero or more key-value pairs, each with the same representation as an array of length two, followed by the close-parenthesis ).

For example, #((a b) (100 200)) represents a table which contains the value b bound to the key a, and the value 200 bound to the key 100.

See the Tables chapter for more.

Whitespace

The parser ignores whitespace, except for marking the boundary between one token and the next. All comments are parsed as whitespace.

A line comment is introduced with a single ; and continues until the next newline.

A block comment begins with #| and ends with |#. Block comments can be nested. An unterminated block comment is a syntax error.

Prefixing a value with #; will turn that entire value into a comment. This can be a convenient way to comment out code:

(when (< a 100)
  (new-handler (+ a 1))
  #;(when handler-enabled?
    (old-handler)))

The comma , is a whitespace character - this can be helpful for visually breaking up long sequences into smaller groups.

(let a b, c d, e f) ; easier to read
(let a b c d e f)   ; harder to read

Abbreviations

Some patterns appear so frequently in GameLisp programs that they've been given dedicated syntax. All of these abbreviations represent an array. For example, if you try to print the two-element array (splay val), it will be printed as ..val.

The abbreviations are:

AbbreviationRepresents
'val(quote val)
`val(backquote val)
~val(unquote val)
[x y](access x y)
..val(splay val)
@val(atsign val)
.val(meth-name val)

In addition, we use curly braces for string interpolation. When a string contains curly braces, it actually represents an array rather than a string. These two lines are exactly equivalent:

"text {a}{b} {c d}"
(template-str "text " a "" b " " c d)

Curly-brace characters in a string can be escaped by doubling them, as in {{ or }}. Raw strings cannot be interpolated.

Type Summary

The seven non-representable primitive types are each discussed in future chapters. For now, a quick summary of all of val's possible types:

NameMeaningTruthy?Reference?
nilAbsent or undefined valueNoNo
boolBooleanVariesNo
int32-bit signed integerYesNo
flo32-bit floating-point numberYesNo
charCharacterYesNo
symSymbolYesYes
arrArrayYesYes
strStringYesYes
tabTableYesYes
iterIteratorYesYes
objObjectYesYes
classClassYesYes
fnFunctionYesYes
coroCoroutineYesYes
rfnRust functionYesYes
rdataRust dataYesYes

Abstract Types

Abstract types group together multiple primitive types which share a common API.

Abstract TypeAPIConcrete Types
numNumberint, flo
dequeDouble-ended queuearr, str
callableFunction-call receiverfn, rfn, class
expanderMacro expanderfn, rfn
iterableIterablearr, str, tab, coro, iter

Evaluation

In the previous chapter, we introduced the fact that GameLisp code is made out of a tree of simple values. Those values are sometimes parsed directly from a text file, but they can also be generated dynamically.

Unlike Rust, GameLisp code does not need to be compiled into a binary before it can be run. You just pass your code to GameLisp, and it's executed straight away.

This chapter will describe precisely what happens when GameLisp is asked to run a particular value as a piece of code.

Basic Evaluation

"Evaluating" a value means executing it as a piece of code. When we're talking about evaluation, we tend to say "form" rather than "value", but they're just two different names for the same thing. When a form is evaluated, it either returns another value or triggers an error.

Values of type nil, bool, int, flo, char, str or tab are self-evaluating. If you evaluate a form of one of these types, no code is executed; the "evaluation" simply returns the form itself.

Symbols evaluate to the current value of the variable which they name. Local variable bindings take precedence over global bindings. If there are no local or global bindings for the symbol which is being evaluated, it's an error.

Arrays are a little more complicated:

  • Empty arrays are self-evaluating.

  • When the array's first element is a symbol which names a special form, such as quote, if or do, then that form's special evaluation rules are applied.

  • Otherwise, the array is evaluated as a function call. All of its elements are evaluated in order, from left to right. The result of evaluating the leftmost form becomes the "callee", the function which is being called. The results of evaluating all of the other forms (if any) are passed to the call as its arguments. The function call's return value becomes the result of the evaluation.

Attempting to evaluate a form of any non-representable type is an error.

A callee is usually a function. Functions can be defined either in GameLisp or in Rust. GameLisp comes with a very large number of built-in functions in its standard library.

For now, the main built-in functions you need to know are:

  • prn takes any number of arguments and prints their text representation to stdout, separated by spaces and followed by a newline. pr does the same thing without the newline. Both functions return #n.

  • int?, tab? and similar functions accept a single argument. They return #t if the argument belongs to the specified type, or #f otherwise.

  • <, ==, >= and so on perform numeric comparisons. +, -, % and so on perform basic arithmetic on numbers. It's an error to pass a non-number argument to any of these functions.

    • Note that GameLisp's arithmetic and comparisons use prefix notation rather than the more familiar infix notation. Rather than writing 1 + 2 + 3, write (+ 1 2 3); and rather than writing i < len, write (< i len).
  • arr constructs a new array which contains all of its arguments. For example, (arr) will return a new empty array, and (arr 1 2 3) will return a new array which contains those three numbers.

    • You can "splay" the contents of one array into another with the abbreviation ..; for example, (arr 10 ..src) will create a new array which contains the integer 10, followed by all of the elements from the array src.

A few examples of nested function calls:

(prn (> (+ 1 1 1) 2)) ; prints #t
(prn (pr "a ") (pr "b ")) ; prints a b #n #n
(prn (nil? #n) (int? 10) (bool? (int? "hello"))) ; prints #t #t #t
(prn (nil? 1 2)) ; error: too many arguments

Special Forms

"Special forms" are those which don't follow the basic evaluation rules described above. Because GameLisp's macro facilities are so powerful, it has a relatively small number of special forms, compared to languages like Rust.

do has the same effect as a block { } in Rust. (do a b c) evaluates a, then evaluates b, then finally evaluates c, returning its value. (do), with no arguments, evaluates to #n.

Many other forms establish an "implicit do". They contain zero or more child forms which are evaluated one after the other, and they return the result of evaluating their last child form.

quote suppresses evaluation: (quote a) returns the value of a without evaluating it. Remember that quote is usually abbreviated as ', so we would write (quote val) as 'val. Quote is most often used either to stop a symbol from being evaluated as a variable, or to stop an array from being evaluated as a function call:

; without the quotes, this would be evaluated as printing the values of 
; variables named "hello" and "world". with the quotes, it prints two
; symbols instead.
(prn 'hello 'world)

; without the quote, this would be evaluated as a call to the function 
; named "alice", passing in the values of the variables "betty" 
; and "carlo" as arguments, and then printing the call's return value. 
; with the quote, it prints a literal array instead.
(prn '(alice betty carlo))

if performs conditional evaluation. It must always receive three forms: a "condition" form, a "then" form, and an "else" form. First, the "condition" form is evaluated. If its result is truthy, the "then" form is evaluated and returned; otherwise, the "else" form is evaluated and returned.

; prints 5, but doesn't print 4
(prn (if (> 2 1) 
  5 
  (prn 4)))

Local Variables

GameLisp's local variables are lexically scoped and always mutable. They can be declared either at the toplevel of a source file, or within an implicit do.

Just like Rust, you declare a new local variable by using the let special form. Its first argument should be a symbol, and its optional second argument is a form which will be evaluated to initialize the variable. When the second argument is absent, the variable's initial value will be #n.

(let a 20)
(let b (* 20 a))
(prn b) ; prints 400

When let has three or more arguments, it's equivalent to multiple consecutive let forms with one or two arguments each. We could rewrite the above as:

(let a 20, b (* 20 a))
(prn b) ; prints 400

let's first argument can be an arbitrary pattern rather than a symbol, but we'll discuss that in a later chapter.

Global Variables

When evaluating a symbol which isn't bound to a local variable, it's assumed to refer to a global variable instead. The symbol a-name will evaluate to the current value of the global variable binding for the symbol a-name, or it will trigger an error if there's no such binding.

GameLisp's global variables use late binding. This means that every time a global variable is accessed or mutated, that variable's binding is looked up at the last possible moment. If you're trying to access a global variable which doesn't exist, you won't find out when the code is loaded - the error is delayed until you actually try to access it. On the other hand, late binding means that there's no need to declare your variables in advance, so you won't have to struggle with order-of-declaration problems or circular dependencies.

Global bindings can be dynamically created, destroyed, accessed or mutated by calling the built-in functions bind-global!, del-global!, global and global=.

Built-in function calls like (prn 'hello) make use of global variables. When the GameLisp runtime starts up, the symbol prn is bound to a global variable. The initial value of that global variable is set to a Rust function which prints its arguments.

A local variable "shadows" a global variable which is bound to the same name:

(prn 'hello) ; prints hello
(let prn 10)
(prn 'world) ; error: callee is an int

Late binding enables some useful tricks. For example, if you're creating a 2D game and you find it inconvenient to look up your sprites in a hash table, you could arrange for them to be automatically bound to global variables instead:

(draw spr:zombie-head (- x 10) (- y 10))
(draw spr:zombie-body (- x 10) y)

Functions

Functions in GameLisp are like closures in Rust, in that they can be stored as the value of a variable. Functions defined in GameLisp have the type fn, while functions defined in Rust have the type rfn.

The fn special form defines a new function, which can then be called just like one of the built-in functions:

(let triple (fn (n)
  (let doubled (+ n n))
  (+ n doubled)))

(prn (triple 70)) ; prints 210

The body of a function establishes an "implicit do", like the do special form. Each of its body forms are evaluated one after the other, and the value of the final form is returned as the result of the function call. You can return early using the return special form.

By default, a function only accepts a fixed number of arguments:

(prn (triple)) ; error: too few arguments
(prn (triple 1 2)) ; error: too many arguments

However, a fn's parameter list is a pattern. Among other things, this allows you to describe an optional parameter with the special syntax (? name default-val), or capture zero or more arguments with the special syntax ..name.

(let sum-and-triple (fn (..nums)
  (* 3 (+ ..nums))))

(prn (sum-and-triple 10 20 30)) ; prints 180

(let print-multi (fn (item (? times 1))
  (forn (_ times)
    (pr item " "))
  (prn)))

(print-multi 'badgers) ; prints badgers 
(print-multi 'badgers 3) ; prints badgers badgers badgers 

Functions capture the lexical environment at their definition site. This means that you don't need to worry about local variables going out of scope:

(let f (do
  (let captured 10)
  (fn ()
    captured)))

(prn (f)) ; prints 10, even though `captured` is out-of-scope

Macros

We've now described how GameLisp's parser converts text into data, and how GameLisp's evaluator executes data as code.

To complete the picture, there's one more step that we need to discuss. Just before evaluating any code, GameLisp's expander performs transformations on the code tree.

It's easy to hook in your own functions to customize the behaviour of the expander. These functions, which transform GameLisp data/code into different GameLisp data/code, are known as macros.

The Expansion Algorithm

This is the basic algorithm which GameLisp uses to expand a form in place:

  1. If the form is not an array, or if it's an empty array, then no expansion is performed.

  2. Expand the array's first element in place.

  3. If the array's first element is now a symbol, and that symbol is currently bound to a macro function, then invoke that macro function, passing in all of the array's elements as arguments. Replace the array with the result of the macro invocation, then restart from step 1.

  4. If the array doesn't start with quote, expand each of its arguments in place, from left to right (starting with the array's second element, then moving on to its third element, and so on.)

In simple terms, this algorithm traverses the entire source tree, looking for arrays which resemble function calls. When the array's "callee" is a symbol which is bound to a macro, that macro is called like a function, and then the entire array is replaced with the result of the call.

; if the symbol `name` is bound to a macro, the expander will 
; invoke ((macro 'name) a b c), and then replace the (name a b c) 
; form with the result of that call.
(name a b c)

The (splice) macro is a special case: it's replaced by each of its argument forms, inserted next to one another in-place.

(some-function (splice a) b (splice c d) (splice) e)

; after macro-expansion...
(some-function a b c d e)

Binding Macros

A macro is just a normal GameLisp function, of type fn or rfn. It takes zero or more forms as its input, and produces one form as its output.

Like variables, macros can be bound globally or locally. To manipulate a symbol's global macro binding, use the builtin functions bind-macro!, del-macro!, macro and macro=.

To introduce a local macro binding, you can use the let-macro special form wherever you would use let.

; these two forms are similar, but the first form binds its
; fn to a local variable, and the second form binds its fn
; to a local macro.

(let add (fn (a b)
  (+ a b)))

(let-macro add (a b)
  (+ a b))

Example: when and unless

Recall the syntax for the if special form: (if condition then else).

This syntax is easy to understand when working with very small forms which fit onto a single line, but when working with complicated multi-line forms, it's not always ideal.

; at a glance, you might mistakenly think that this form will 
; print both lines when i is less than 100
(if (< i 100)
  (prn "first line")
  (prn "second line"))

; the do and #n forms here add a lot of visual noise
(if drawing-suppressed?
  #n
  (do
    (draw-rect x y w h)
    (draw-circle center-x center-y radius)))

This is exactly the type of problem which can be solved using macros: we have a small bit of awkward, repetitive or confusing syntax, and we'd like to make it more clear.

The when macro is shorthand for "if the condition is true, evaluate a do form; otherwise, evaluate #n".

(bind-macro! 'when (fn (condition-form ..do-forms)
  `(if ~condition-form
    (do
      ~..do-forms)
    #n)))

(when (< i 100)
  (prn "first line")
  (prn "second line"))

The unless macro is similar, but it evaluates the do form when its condition is false.

(bind-macro! 'unless (fn (condition-form ..do-forms)
  `(if ~condition-form
    #n
    (do
      ~..do-forms))))

(unless drawing-suppressed?
  (draw-rect x y w h)
  (draw-circle center-x center-y radius))

Quasi-quotation

Our when and unless macro definitions introduced several unfamiliar pieces of syntax:

Recall that the quote special form, abbreviated ', suppresses evaluation. If you want to produce a tree of nested arrays, then rather than constructing them element-by-element, it's much simpler to just write them down as text and quote them so that they're returned verbatim.

; these two forms produce equivalent results

'((one eins) (two zwei) (three drei))

(arr (arr 'one 'eins) (arr 'two 'zwei) (arr 'three 'drei))

backquote is like quote, but more powerful.

Firstly, backquote always allocates an entirely new, mutable array (whereas quote returns a shared, immutable array - this is usually more efficient, but it's not always what you want). It's like a shorthand for the (arr ...) constructor.

Secondly, backquote allows evaluated forms and quoted forms to be mixed with one another. This is called "quasi-quotation", because only part of the form is quoted. If you want some forms within a backquote to be evaluated while building the array, prefix them with ~. All other forms will be quoted.

(let one-de 'eins)
(let two-de (fn () 'zwei))

; the following three forms all produce equivalent results

'((one eins) (two zwei) (three drei))

`((one ~one-de) (two ~(two-de)) (three ~(sym "drei")))

(arr (arr 'one one-de) (arr 'two (two-de)) (arr 'three (sym "drei")))

We briefly mentioned in the previous chapter that prefixing one of arr's arguments with .. will cause the full contents of that argument to be "splayed" into the resulting arr. This is particularly useful when working with backquote and unquote, as we demonstrated with when and unless above.

macro-no-op

Within a macro, you can call the built-in function (macro-no-op). This immediately cancels execution of the macro, and it suppresses step 3 in the algorithm. The current form is left as it is, and the algorithm proceeds to step 4, expanding the form's children as normal.

This enables the same name to be simultaneously bound both to a macro, and to a function or special form. Without (macro-no-op), it would be impossible for a macro to expand to a function call which shares the same name, because it would trigger an endless loop: (the-fn a b) would expand to (the-fn a b) which would expand to (the-fn a b)... but (macro-no-op) breaks that cycle.

We've previously mentioned that the let special form can accept an arbitrary number of arguments: with three or more arguments, it behaves like multiple consecutive calls to let. This is actually achieved using a macro which is globally bound to the let symbol:

; this form...
(let a b, c d)

; ...expands to this one:
(splice
  (let a b)
  (let c d))

; the `splice` macro can expand into multiple forms, so it becomes:
(let a b)
(let c d)

; each of those (let) forms also invokes the (let) macro, but when that
; macro is invoked with two or fewer arguments, it just calls (macro-no-op)

More generally, macros and variables occupy entirely different namespaces. A particular symbol can be bound to a global variable, a macro, both, or neither. A local variable binding will not shadow a local or global macro, and let-macro will not shadow a local or global variable.

Order of Expansion

There's a small problem with our expansion algorithm: if global macros are defined dynamically during evaluation, and if code is expanded before being evaluated, how is it that a global macro defined early in a source file can be used to expand code later on in that same source file?

(bind-macro! 'fizz (fn ()
  `(prn "fizz")))

; why can we use the 'fizz macro to expand this form?
(fizz)

It wouldn't be possible for us to interleave expansion with evaluation, because that would be much too slow: we would need to re-expand every form every single time that it's evaluated. This would force GameLisp to be an AST-walking interpreter, making it many times slower than it is today. It's essential that we have a separate expansion step before evaluation.

We could set up the expander so that it's capable of detecting bind-macro! calls and evaluating them during expansion, but that would mean that bind-macro! is no longer truly dynamic. Patterns like this would stop working:

(unless file-access-disabled?
  (bind-macro! 'write-file (fn (file content)
    `(stream-to-file ~file ~content))))

The solution is a simple compromise. When working their way through the forms in a GameLisp source file, the expander and evaluator "take turns". The first form is expanded; then the first form is evaluated; then the second form is expanded; then the second form is evaluated... and so on.

This means that a global macro binding can be used later on in the same source file, but it can't be used later on in the same toplevel form. If you want to define a macro to be used locally, you should use let-macro instead.

(do
  (bind-macro! 'fizz (fn ()
    `(prn "fizz")))

  (fizz)) ; this doesn't work...

(fizz) ; ...but this does

(do
  (let-macro fizz ()
    `(prn "fizz"))

  (fizz)) ; ...and this does

Toplevel Scopes

Although evaluation and expansion do only process one toplevel form at a time, that's not the whole story. If each toplevel form were completely separate from the next, it would be impossible to use forms like let or let-macro at the toplevel.

We say that under some circumstances, a group of forms is processed in a "toplevel scope". For example, when loading a GameLisp source file, the toplevel scope continues until the end of that file. This means that a toplevel let form works exactly as you would expect.

Evaluation APIs

The function for running a GameLisp source file is called load. It opens a source file, reads it into a string, parses that string into a series of forms, and expands and evaluates each form in the same toplevel scope.

GameLisp projects are likely to end up with a modular, branching tree of files: the source file main.glsp calls (load "engine.glsp"), which calls (load "engine-drawing.glsp"), and so on. In order to prevent the same source file from accidentally being loaded twice, we provide the function require. If the current GameLisp runtime has already received a require call for the specified file, it silently does nothing. Otherwise, require is exactly the same as load.

eval and eval-multi are like load, but they receive their forms as arguments rather than parsing them from a string. eval-multi accepts an array of forms, expanding and evaluating those forms one after the other, all in the same toplevel scope. eval is the same, but its argument is a single form rather than an array of forms.

Hygiene

If you've worked with Rust's macro_rules! macros, you'll know that they're hygienic: it's not possible for a macro to name a local variable from outside its own lexical scope, unless that name was passed in as one of the macro's arguments.


#![allow(unused_variables)]
fn main() {
macro_rules! unhygienic_macro {
	() => (println!("{}", local_name));
};

fn scope() {
	let local_name = 42;

	macro_rules! hygienic_macro {
		() => (println!("{}", local_name));
	};

	hygienic_macro!(); //prints 42
	unhygienic_macro!(); //error: cannot find value `local_name` in this scope
}
}

This is good for program correctness, but it can also be inconvenient. Because GameLisp values convenience very highly, GameLisp's macros are unhygienic.

The main risks when working with unhygienic macros are that you may unintentionally refer to a local variable when you mean to refer to a global one, or you may "leak" a local variable which is supposed to be private to the macro's implementation.

; this macro checks that adding 1 to 2 produces 3 before executing a
; block of code. defensive programming!
(bind-macro! 'with-add-assertion (fn (..body)
  `(do
    (let tmp 1)
    (unless (== (+ tmp 2) 3) 
      (bail "addition is broken!"))
    ~..body)))

; prints 1 rather than 5: addition is broken, but we don't detect it 
; because `bail` is also broken!
(do
  (let + -)
  (let bail (fn (ignored) #n))
  (with-add-assertion
  	(let x 3)
    (prn (+ x 2))))

; the macro shadows `tmp`, printing 1 rather than the expected 7
(let tmp 7)
(with-add-assertion
  (prn tmp)) 

You can solve the first problem by explicitly accessing the global rather than local version of a name. (However, this should rarely be necessary - it's unusual for a global name to be shadowed by a local one, and when it does happen, it's something that the overrider probably wants to affect all nested code, including code generated by macros.)

(bind-macro! 'with-add-assertion (fn (..body)
  `(do
    (let tmp 1)
    (unless (== (+ tmp 2) 3) 
      ((global 'bail) "addition is broken!"))
    ~..body)))

The second problem is more insidious and important: macros often need to define temporary variables, and needing to come up with a unique name for each of them would be painful. This is how the C preprocessor ended up with silly names like __FILE__ and __STDC_VERSION__.

We solve this problem using gensym, a built-in function which returns a unique symbol. If you use a fresh gensym for each local binding generated by a macro, you don't need to worry about accidentally shadowing the user's own variables:

(bind-macro! 'with-add-assertion (fn (..body)
  (let temp-sym (gensym))

  `(do
    (let ~tmp-sym 1)
    (unless (== (+ ~tmp-sym 2) 3) 
      (bail "addition is broken!"))
    ~..body)))

This is such a common pattern that we have special syntax for it: when backquote detects a quoted symbol which ends with #, it replaces each occurence of that symbol with the result of a (gensym).

(prn `(foo# foo# bar#)) ; prints #<gs:foo:0> #<gs:foo:0> #<gs:bar:1> 

(bind-macro! 'with-add-assertion (fn (..body)
  `(do
    (let tmp# 1)
    (unless (== (+ tmp# 2) 3) 
      (bail "addition is broken!"))
    ~..body)))

Manual Expansion

You'll sometimes want to expand a form without immediately evaluating it. For example, when writing a macro, you might want to perform some processing on the expanded version of its arguments, rather than the unexpanded version.

The built-in function expand takes a single value as its argument, recursively expands it according to the usual algorithm, and then returns the expanded result.

On the other hand, expand-1 gives you finer control over the expansion process by performing a single "step" of the expansion algorithm at a time. It also allows you to override the expansion function, and to detect when macro-no-op is called.

Built-in Macros

In this chapter, we'll explore a few of the macros built in to GameLisp's standard library.

While reading through, bear in mind that none of these macros are doing anything which requires special support from the runtime - if they didn't exist, it would be possible for you to implement all of them yourself, using the information you learned in the previous chapter.

Control Flow

The when and unless macros were introduced previously.

while, loop, until

while and loop are similar to their Rust counterparts:

(let i 0)
(while (< i 5)
  (inc! i)
  (pr i " ")) ; prints 1 2 3 4 

(loop
  (prn "around and around we go"))

until is like while, but it terminates the loop when its condition is "truthy", rather than stopping when it's "falsy". We could rewrite the above while loop as:

(let i 0)
(until (>= i 5)
  (inc! i)
  (pr i " ")) ; prints 1 2 3 4 

The break and continue macros work as they do in Rust. By default, all looping constructs return #n, but break-with-value is supported:

(prn (loop
  (break 10))) ; prints 10

and, or

The and and or macros provide lazy boolean evaluation, just like Rust's && and || operators.

and evaluates each of its arguments from left to right. If one of them returns a "falsy" value, the and form returns that value and does not evaluate any more arguments. Otherwise, it returns the result of evaluating its rightmost argument.

Similarly, or evaluates each of its arguments from left to right, stopping as soon as it produces a "truthy" value.

cond

The cond macro is reminiscent of Rust's match, but it's simpler. (GameLisp does also have a full fledged match macro, which we'll discuss in a future chapter.)

cond receives a series of clauses, where the first form in each clause is a condition. It checks each condition in turn, evaluating the body of the first clause whose condition is "truthy", and returning the evaluation result of the last form in that body.

(cond
  (condition-0   ; if this condition is truthy...
    then-0)        ; this form is evaluated and returned
  (condition-1   ; otherwise, if this condition is truthy...
    then-1a        ; this form is evaluated, then...
    then-1b)       ; this form is evaluated and returned
  (condition-2)  ; otherwise, if this condition is truthy, it's returned
  (else          ; otherwise...
    then-e))       ; this form is evaluated and returned

The final clause's condition can be the symbol else, in which case the condition always passes. If there is no else clause and none of the conditions are "truthy", the cond form returns #n.

If a clause only contains a condition, without any other forms, then that clause's return value is the result of evaluating the condition itself.

When a branching expression is long enough that it needs to be split across multiple lines, and it has both a "then" branch and an "else" branch, it's good practice to use cond rather than if:

; can be confusing
(if (eq? emotion 'angry)
  (draw 0 0 w h 'crimson)
  (draw 3 3 (- w 3) (- h 3) 'cerulean))

; more verbose, but easier to edit and less confusing
(cond
  ((eq? emotion 'angry)
    (draw 0 0 w h 'crimson))
  (else
    (draw 3 3 (- w 3) (- h 3) 'cerulean)))

Defining Globals

We introduced bind-global! in the Evaluation chapter. bind-global! is such a common operation that we provide a macro shorthand for it, def.

(def clip:door-open (load-clip "door-open.mp3"))

; ...is equivalent to...

(bind-global! 'clip:door-open (load-clip "door-open.mp3"))

Bear in mind that bind-global! will trigger an error if its global is already bound, so you can't necessarily use def where you would use let. It's generally only used at the toplevel.

def's syntax is otherwise identical to let: it can accept three or more arguments (equivalent to multiple consecutive def forms), and it performs destructuring on its left-hand side.

defn is a similar shorthand for defining global functions:

(defn brew (cauldron)
  (push! cauldron 'eye-of-newt))

; ...is equivalent to...

(bind-global! 'brew (fn &name brew (cauldron)
  (push! cauldron 'eye-of-newt)))

And we have defmacro for global macros:

(defmacro with-pointy-hat (..body)
  `(do
    (put-on-hat)
    ~..body
    (remove-hat)))

; ...is equivalent to...

(bind-macro! 'with-pointy-hat (fn &name with-pointy-hat (..body)
  `(do
    (put-on-hat)
    ~..body
    (remove-hat))))

In both cases, a &name clause is used to assign a symbol as the function's name for debugging purposes. This means that defn and defmacro generally lead to a nicer debugging experience, compared to using bind-global! and bind-macro! directly.

Recursive Local Functions

Defining a local function which calls itself recursively can be awkward:

(let recurse (fn (n)
  (cond 
    ((> n 5)
      (recurse (- n 1)))
    (else
      (prn n)))))

(recurse 10.5) ; error: unbound symbol 'recurse'

The outer call to recurse works as expected, but the let binding is not in scope for the recursive call, so it attempts to access a global variable named recurse instead.

The let-fn macro solves this problem:

(let-fn recurse (n)
  (cond 
    ((> n 5)
      (recurse (- n 1)))
    (else
      (prn n))))

(recurse 10.5) ; prints 4.5

This works by initializing the local variable to #n first, and then immediately assigning the function to it, so that the local binding is in scope throughout the function's body. It's equivalent to:

(let recurse)
(= recurse (fn (n) ...))

Assignment

In GameLisp, all variables and collections are mutable by default.

The = macro can be used to assign a new value to a place. A place might be a global or local variable, an array element, a table value, or any number of other things.

Simple use of = looks like this:

(let a 10, b 20, c 30)

(= a 40)
(= b 50, c 60)

(prn (+ a b c)) ; prints 150

When ='s first argument is a function call, it will inspect the name of the function, then replace the entire = form with a call to the corresponding "setter" function.

(= (macro 'brew) (fn () #n))
(= (global 'cursed?) #t)

; ...is equivalent to...

(macro= 'brew (fn () #n))
(global= 'cursed? #t)

Naming Conventions

Functions which assign a value to some memory location are suffixed with =, while functions which perform other kinds of mutation are suffixed with !.

; reads as "set the global cursed? to #t"
(global= 'cursed? #t)

; reads as "push the new-cat to the arr-of-cats"
(push! arr-of-cats new-cat)

; reads as "increment the cauldron-weight"
(inc! cauldron-weight)

Where possible, it's good style to use the = macro, rather than calling a setter function directly. The = macro is easier to spot when visually scanning through the source code.

In-Place Mutation

It's a very common operation to read the value currently stored in a place, pass it to a function (perhaps with some other arguments), and then assign the function's return value to that same place. We provide a number of macros to make this easier.

(let n 0)
(inc! n 5) ; add 5 to n
(mul! n 2 2 2) ; multiply n by 2, three times
(prn n) ; prints 40

inc!, dec!, mul!, div!, div-euclid!, rem!, rem-euclid!, abs! and clamp! can be used to perform basic arithmetic in-place.

The (swap! a b) macro swaps the values stored in any two places.

; the two places can be different from one another
(swap! my-local-var (global 'my-global-var))
(swap! [my-arr 0] [my-arr -1])

Arrows

The -> and ->> macros perform a simple syntax transformation which can make deeply-nested function calls easier to read.

The -> macro accepts a form followed by any number of arrays or symbols. The form is evaluated as normal, and it's then passed through a chain of function calls, where the return value of each call acts as the first argument to the next call.

(fourth (third (second (first a) b) c))

; ...could be refactored into...

(-> (first a) (second b) (third c) fourth)

; ...which is equivalent to...

(do
  (let tmp-1 (first a))
  (let tmp-2 (second tmp-1 b))
  (let tmp-3 (third tmp-2 c))
  (fourth tmp-3))

The ->> macro is similar, except that it passes in each form's result as the last argument to the following call, rather than its first argument.

(fourth (third c (second b (first a))))

; ...could be refactored into...

(->> (first a) (second b) (third c) fourth)

; ...which is equivalent to...

(do
  (let tmp-1 (first a))
  (let tmp-2 (second b tmp-1))
  (let tmp-3 (third c tmp-2))
  (fourth tmp-3))

When you have a several function calls deeply nested inside one other, it's usually possible to refactor them with either -> or ->>. Having data flow from start to finish can be much easier to follow, compared to data which travels up and down a call-tree.

(+ ..(map (fn (n) (* n 2)) (filter even? (arr 1 2 3 4))))

; ...could be refactored into...

(->> 
  (arr 1 2 3 4)
  (filter even?)
  (map (fn (n) (* n 2)))
  ..+)

It's a little like a chain of method calls. In Rust, the above would be written as:


#![allow(unused_variables)]
fn main() {
[1, 2, 3, 4]
	.filter(is_even)
	.map(|n| n * 2)
	.sum()
}

Numbers

Compared to other Lisps, GameLisp's numbers are relatively simple. A number may only be an int (a 32-bit signed integer) or a flo (a 32-bit IEEE float). Rational fractions, arbitrary-precision integers and complex numbers are not supported.

The API for manipulating numbers is mostly unsurprising - the full list of functions is available here. Some minor points:

  • Bit manipulation is supported for integers.

  • We provide a simple random number generator.

  • There is a distinction between sign (which returns 0, 1 or -1 for any number) and flo‑sign (which returns the sign of a float as 1.0, -1.0 or nan.0).

(prn (sign -0.0)) ; prints 0
(prn (flo-sign -0.0)) ; prints -1.0

Promotion to Float

Floats are "contagious". When a function such as + receives both an integer and a float among its arguments, the integer is promoted to a float before performing the operation, so the return value is always a float.

(prn (+ 1 2 3 4)) ; prints 10
(prn (+ 1 2 3.0 4)) ; prints 10.0

(prn (/ 7 2)) ; prints 3
(prn (/ 7 2.0)) ; prints 3.5

(prn (% 5 1.5)) ; prints 0.5

Operations like min and clamp are the exception to the rule, because they return one of their arguments unchanged.

(prn (max 1 2 3.0 4)) ; prints 4
(prn (clamp 1.0 3 5.0)) ; prints 3

Wrapping Arithmetic

Integer arithmetic is always unchecked (wrapping), even when your crate is compiled in debug mode.

(prn (+ 1 2147483647)) ; prints -2147483648

This helps to keep the language simple. If GameLisp were to take Rust's approach to integer overflow, it would need to provide distinct APIs for normal, wrapping and checked arithmetic. In Rust, this adds a large complexity burden when working with integers.

Out of those three options, wrapping arithmetic was chosen over checked arithmetic in order to make fatal errors less likely. In game development, overflowing an entity's coordinates and getting a nonsensical value will usually be a minor bug, but overflowing an entity's coordinates and crashing the game's executable would be catastrophic.

Arrays

GameLisp's general-purpose sequential data structure is called an "array", but it's actually a VecDeque.

A VecDeque is a growable ring buffer. It's very similar to a Vec, but with the added ability to push and pop items from the start of the sequence in constant time. (Note that a VecDeque is very different from a C++ std::deque, which has an odd memory-allocation patterns - VecDeque allocates all of its elements in a single buffer, just like a Vec.)

Basic Functions

The arr function will return a new array which contains all of its arguments. When one of the arguments is an array, and that argument is prefixed with .., all of that array's elements are copied into the array which is being constructed.

(prn (arr)) ; prints ()
(prn (arr 1 2 3 4)) ; prints (1 2 3 4)

(let src '(x y z))
(prn (arr 1 2 src 3 4)) ; prints (1 2 (x y z) 3 4)
(prn (arr 1 2 ..src 3 4)) ; prints (1 2 x y z 3 4)
(prn (arr ..src ..src)) ; prints (x y z x y z)

len returns the array's length, and empty? tests whether it has a length of 0.

(prn (len '())) ; prints 0
(prn (len (arr 'a 'b))) ; prints 2

(prn (empty? (arr))) ; prints #t
(prn (empty? '(1))) ; prints #f

An array constructed using the arr function will be mutable. You can add any number of elements to the start or end of an array using the functions push-start! and push!, or you can remove one element at a time using pop-start! and pop!.

(let metals (arr 'pewter 'silver 'copper))

(push! metals 'iron 'bronze)
(prn metals) ; prints (pewter silver copper iron bronze)

(prn (pop! metals)) ; prints bronze
(prn (pop! metals)) ; prints iron
(prn (pop-start! metals)) ; prints pewter
(prn metals) ; prints (silver copper)

(push-start! metals 'titanium 'electrum)
(prn metals) ; prints (titanium electrum silver copper)

Many other useful functions are described in the standard library documentation.

Indexing

The macro for looking up an element in any collection is called access. To get the first element of an array, you might call (access the-array 0).

Because access is such a fundamental operation, it can be abbreviated using square brackets. (access the-array 0) would normally be written as [the-array 0] instead. Notice that this resembles the equivalent Rust syntax: the_array[0].

Negative indexes count backwards from the end of the array. [names -1] returns the last element in the names array, and [names -2] returns its second-to-last element.

Array elements are places, so they can be mutated using the = macro.

(let blues (arr 'azure 'sapphire 'navy))

(prn [blues 2]) ; prints navy
(prn [blues -3]) ; prints azure

(= [blues 0] 'cerulean)
(= [blues -2] 'cobalt)

(prn blues) ; prints (cerulean cobalt navy)

Slicing

The access macro, and therefore the [] abbreviation, both support special syntax for accessing multiple consecutive elements in an array. They use the : symbol, similar to Python's slice syntax.

(let alphabet '(a b c d e f g h i j k l m n o p q r s t u v w x y z))

; elements from n to m are sliced using `n : m`
(prn [alphabet 3 : 8]) ; prints (d e f g h)
(prn [alphabet -10 : 21]) ; prints (q r s t u)
(prn [alphabet 5 : 5]) ; prints ()

; elements from 0 to n are sliced using `: n`
(prn [alphabet : 5]) ; prints (a b c d e)
(prn [alphabet : -23]) ; prints (a b c)
(prn [alphabet : 30]) ; an error

; elements from n to the end are sliced using `n :`
(prn [alphabet 23 :]) ; prints (x y z)
(prn [alphabet -1 :]) ; prints (z)

; the entire array can be sliced using `:`
(prn (eq? [alphabet :] alphabet)) ; prints #t

; the `:` symbol is whitespace-sensitive
(prn [alphabet 2:5]) ; an error
(prn [alphabet 3:]) ; an error

To keep things simple, all of these slicing operations will allocate, and return, a new array. Unlike Rust, there's no way to produce a reference which points into an array's interior.

The del! and remove! macros also support the same slicing syntax.

(del! names 3) ; delete element 3
(del! names 2 : 5) ; delete elements 2, 3 and 4
(del! names :) ; delete every element

A slice is a place. Assigning an array to a slice will overwrite all of the elements stored there, changing the size of the array if necessary.

(let numbers (arr 0 1 2 3 4 5 6 7 8 9))

(= [numbers : 6] '())
(prn numbers) ; prints (6 7 8 9)

(= [numbers -2 :] (arr 42 42 42))
(prn numbers) ; prints (6 7 42 42 42)

(= [numbers :] '(5 5 5))
(prn numbers) ; prints (5 5 5)

GameLisp doesn't include some of the traditional Lisp functions for processing sequences, like rest, butlast, take and drop. All of those functions can be expressed in a more versatile and general way using the slice syntax.

Arrows

Deeply-nested use of the [] syntax can sometimes be visually confusing.

[[[the-array index] 0] start-index :]

"Arrow macros" were discussed in the previous chapter. The [] syntax works well when used with the -> macro:

(-> the-array [index] [0] [start-index :])

This is similar to the equivalent Rust syntax:

the_array[index][0][start_index..]

Strings and Text

In GameLisp, a string is an array which can only store characters.

In fact, strings support the full array API described in the previous chapter: len, push-start!, remove!, indexing with integers, slicing, and so on. The only difference is that assigning a non-character value to a string is an error.

This enables you to write code which is generic over both strings and arrays. For example, a function which reverses a string or array in-place:

(defn rev! (deq)
  (ensure (deque? deq))

  (forn (i (/ (len deq) 2))
    (swap! [deq i] [deq (- i)]))

  deq)

Notice that we test for the type deque, which is the abstract type implemented by anything which supports an array-like interface. For a string, arr? would return #f, but deque? returns #t.

String Storage

Because our double-ended queue API requires constant-time random access, we can't encode strings using UTF-8: locating the nth character in a UTF-8 string is an O(n) operation.

Instead, we take a leaf out of Python's book. By default, a string will use a VecDeque<u8> for its backing storage. The first time a character with a scalar value above 255 is assigned to the string, it switches to a VecDeque<u16>. Similarly, any scalar value above 65535 will cause the storage to be converted to a VecDeque<char>.

This scheme has good performance characteristics. When compared to UTF-8, it typically uses equal or less storage, except when a string contains text from multiple scripts. Each string will change its character width at most two times; strings which only contain ASCII and the Latin-1 Supplement will never change their character width; and most non-Latin strings will typically change their character width zero or one times, rather than two.

Converting Values to Strings

Any value can be converted to text using the str function. It accepts zero or more arguments; inserts spaces between adjacent non-character, non-string arguments; converts each of those arguments to text; and returns the concatenation of all of the arguments' text as a newly-allocated, mutable string.

(str)                      ; ""
(str \a \b \c)             ; "abc"
(str 1 2 3)                ; "1 2 3": note the spaces
(str 1 " " 2 " " 3)        ; "1 2 3": equivalent to the previous call
(str "hello" \w "or" "ld") ; "helloworld": no spaces added between strs/chars

This is also how pr and prn process their arguments. prn appends a UNIX-style line ending, "\n", to its output.

The sym function is similar to str, but it doesn't insert spaces between any of its arguments, and it converts the result into a symbol. It's an error if the string is empty, or if it contains anything other than the valid symbol characters. You can test this using the functions valid-sym-char? and valid-sym-str?.

(valid-sym-str? "") ; #f
(valid-sym-str? "hello-world") ; #t
(valid-sym-str? "hello world") ; #f
(valid-sym-str? "42.42") ; #t

(prn (sym "suffixed-" 100)) ; prints suffixed-100
(prn (sym "*invalid()\ncharacters[]")) ; an error

We also support template strings. A template string evaluates to a newly-allocated, mutable string with values printed into it. It's like the format!() macro in Rust, but more convenient.

(let arg 2)
(prn "1 + {arg} = {(+ 1 arg)}") ; prints 1 + 2 = 3

; within curly braces, adjacent values are separated by spaces
(prn "{(+ 1 1) (+ 1 2)} 4 {(+ 1 4)}") ; prints 2 3 4 5

Finally, you'll sometimes want to customize how numbers are printed. (int->str i radix) will convert an integer to a string with the given radix, and (flo->str f places) will convert a floating-point number to a string with the given number of digits after the decimal point.

Non-Representable Values

In the first chapter of this section, we mentioned representable values. A representable value is one which can be converted to a string, and then parsed back from that string, with no loss of information.

It's still possible to print non-representable values, or convert them to a string. The printer will usually prefix them with #< and suffix them with >, to make it obvious that they can't be parsed.

(prn (gensym)) ; prints #<gs:0>
(prn (arr type-of +)) ; prints (#<rfn:type-of> #<rfn:+>)
(prn (fn () #n)) ; prints #<fn>

Parsing and Unparsing

The parser can be invoked manually using the parse-all function, which receives a string as its argument, and returns an array of all of the values parsed from that string. It's an error if the string contains invalid syntax.

When you know that the input contains exactly one form, parse-1 will parse and return that form.

(parse-all "1 (a b)") ; returns the array (1 (a b))
(parse-all "hello") ; returns the array (hello)
(parse-1 "hello") ; returns the symbol hello

You'll sometimes have data which you want to store as text and then read back in later - for example, in a savegame or a configuration file. Under those circumstances, it's important that the data is representable. You'll need to avoid the following:

  • Values which belong to a non-representable type, such as functions or iterators
  • A reference cycle, which would cause the printer to get stuck in an endless loop
  • Symbols like -10 or ..name, which will be read back in as numbers or abbreviations
  • Symbols generated using gensym, including backquote's auto-gensym# feature

You'll also need to double-quote and escape strings, and convert characters to their literal representation, i.e. printing the string "\a" rather than the character \a.

Checking all of these conditions every time would be tedious, so we provide a function unparse which does the work for you. It's similar to str, but it guarantees that if the resulting string is passed to parse-all, the parsed values will be equal to unparse's arguments.

(prn (unparse "w" \x (arr 'y 'z))) ; prints "w" \x (y z)
(prn (str "w" \x (arr 'y 'z))) ; prints wx(y z)

(let non-repr-sym (sym "42"))
(prn (str non-repr-sym)) ; prints 42
(prn (unparse non-repr-sym)) ; an error

Output Streams

By default, pr and prn send their output to the standard output stream.

We also provide epr and eprn, which are identical except that their output goes to the standard error stream.

It's possible for the host Rust program to customize these functions so that they send their output somewhere else - we'll discuss that in Section 2.

Pretty-Printing

Although GameLisp's syntax is easy enough to write, the raw data is not very pleasant to read when printed:

(prn '(cond
  ((>= (len ar) 2)
    (run [ar 0] [ar 1]))
  (else
    (run [ar 0]))))

; prints (cond ((>= (len ar) 2) (run [ar 0] [ar 1])) (else (run [ar 0])))

GameLisp comes with a simple pretty-printer which attempts to format data/code with reasonable whitespace. It's not gold-standard, but it's usually good enough for debugging.

(pretty-prn '(cond
  ((>= (len ar) 2)
    (run [ar 0] [ar 1]))
  (else
    (run [ar 0]))))

#|
  prints:

  (cond
    ((>= (len ar) 2) (run [ar 0] [ar 1]))
    (else (run [ar 0])))
|#

All of the pretty-printing functions only accept a single value, which they convert to a pretty string with no leading or trailing whitespace. Those functions are pretty‑str, pretty‑unparse, pretty‑prn and pretty‑eprn.

Tables

GameLisp's main associative data structure is the table. Tables are HashMaps which can use arbitrary GameLisp data for their keys.

The basic operations are similar to those for an array. You can read or write table entries using square brackets, [tbl key]. Assignment will create an entry if it doesn't already exist. The has? function will tell you whether a key is already present, and the del! and remove! functions will delete an existing entry. len, empty? and clear! all work as expected.

(let strengths (tab))
(= [strengths 'goblin] 3)
(= [strengths 'dragon] 8)

(prn (has? strengths 'goblin)) ; prints #t
(prn (has? strengths 'kobold)) ; prints #f

(prn [strengths 'goblin]) ; prints 3
(prn [strengths 'manticore]) ; an error

(prn (len strengths)) ; prints 2
(clear! strengths)
(prn (empty? strengths)) ; prints #t

Table Construction

Recall that the syntax for table literals is #((key0 value0) (key1 value1)).

To construct a new table dynamically, you can use the tab macro. It receives a number of array forms of length two, and optionally a number of forms which evaluate to tables, each prefixed with ... Each array form, and each entry from each of the tables, is treated as a (key value) pair which is inserted into the table.

(let basic (tab ('a 'b) ('c 'd)))
(let more (tab ('e 'f) ..basic))

(prn more) ; prints #((a b) (c d) (e f)), not necessarily in that order

The extend! function receives a table as its first argument, followed by any number of (key value) two-element arrays. Those key-value pairs are each inserted into the table, overwriting elements which already exist. It's typically used to copy the full contents of one table into another, by treating the source table as an iterator:

(extend! dst-table ..src-table)

Nonexistent Elements

GameLisp is normally very strict when it comes to whether or not an element of a collection exists. If you attempt to access a nonexistent table entry (or a nonexistent global, array index, object field, class field, or function parameter), it's an error.

This is in contrast to some other scripting languages, which return nil or undefined for nonexistent elements. I find that this is not a sensible default: it can cause errors to silently propagate, making refactoring and debugging more difficult.

If you need to access an element which may or may not exist, various macros support the special syntax (? form). This syntax can be used in place of a key or an index. It will cause the operation to succeed and return #n when an element is missing, rather than triggering an error.

(let heights (tab ('mira 165) ('paul 178)))

(prn [heights 'sara]) ; an error
(prn [heights (? 'sara)]) ; prints #n

(let ar (arr 10 20 30 40 50))

(= [ar -8] -20) ; an error
(= [ar (? -8)] -20) ; a silent no-op

(prn (remove! ar 2)) ; prints 30
(prn (remove! ar 7)) ; an error
(prn (remove! ar (? 7))) ; prints #n

(prn (global (? 'possibility))) ; prints #n
(bind-global! 'possibility 100)
(prn (global (? 'possibility))) ; prints 100

Key Equivalence

All hash tables need to enforce an equivalence relation on their keys. They use this equivalence relation to establish whether, when key B is inserted into the table, it should overwrite the entry previously created for key A.

Our hash tables can use any GameLisp data as a key, including #n. The equivalence relation is represented by the function keys-eqv?. This function is very similar to eq?, with a few small changes:

  • Numbers and characters act as distinct keys, even if they're numerically equal. 65, 65.0 and \A are all == to one another, but they're not key-equivalent.

  • All nan.0 floating-point values are key-equivalent to one another.

  • For performance reasons, tables have to be compared for equivalence using same? rather than eq?. This means that two tables can have identical contents, but still be considered distinct when used as table keys.

  • Objects and Rust data can overload eq?, but there's no way to overload keys-eqv?.

Otherwise, table keys mostly work as you would expect. Arrays and strings are key-equivalent when they have the same contents. Other reference types are key-equivalent when they refer to the same object. Value types are equivalent when they have the same type and the same contents.

Iterators

Rust's iterators are a joy to use. They often enable you to replace a dozen lines of imperative code with a single line of declarative code, without obscuring your program's meaning.

This kind of expressiveness seemed like a great fit for GameLisp, so the language comes with an iterator library which is, for the most part, shamelessly copied from Rust. (To be fair, Rust originally copied many of its iterator APIs from Python...)

(let text "Revered. Exalted. Wise.")

(for word in (->> (split text \space) (rev) (map uppercase))
  (pr word " ")) ; prints WISE. EXALTED. REVERED. 

Iteration

The iter primitive type is like a Rust Iterator, except that it's dynamically typed and heap‑allocated. (Don't panic! GameLisp is smart enough to reuse an iterator's heap storage when you're done with it, so the allocation is very cheap.)

The simplest way to allocate a new iterator is by calling (iter x), where x is some kind of collection or sequence. We say that if something can be passed to the iter function, it belongs to the iterable abstract type. The following types are iterable:

  • Arrays iterate over their elements.
  • Strings iterate over their characters.
  • Tables iterate over their entries as (key value) pairs. Each pair is a newly-allocated, two-element array.
    • Alternatively, you can use the (keys tbl) function to iterate over a table's keys, or the (values tbl) function to iterate over its values.
  • Coroutines will be discussed in the next chapter.
  • Passing an iterator to (iter x) is the identity operation - it just returns x.

To advance an iterator and return its next item, use the iter-next! function. If the iterator has no more items, it will return #n.

However, you can't assume that an iterator is finished just because it's returned #n - if so, iteration over the array (1 2 #n 4 5) would stop after the first two items! Instead, you should use the function iter-finished?. If it returns #t, then the iterator has no more items, and the previous #n item should be discarded.

The for Macro

GameLisp comes with a for macro, which is very similar to Rust's for loop. It takes an iterable, and evaluates its body once for each item produced by the iterable, binding that item to a pattern. break and continue work as expected.

(for element in '(1 2 3 4 5)
  (prn (* element 10)))

(for (key value) in table
  (ensure (sym? key))
  (prn "{key}: {value}"))

for isn't doing anything special - it just invokes the iter, iter-next! and iter-finished? functions.

Standard Iterators

GameLisp comes with a large library of built-in iterators. Almost all of Rust's standard iterators are included: enumerate, zip, map, lines, and so on. You can take a look at the standard library documentation for the full list.

Unlike Rust, GameLisp's once and repeat iterators can accept multiple arguments. If you want an empty iterator which won't produce anything, just call (once) with no arguments.

rn counts upwards from one number to another. (rn 5 10) is equivalent to the Rust iterator 5 .. 10, and (rn 8) is equivalent to 0 .. 8. If you need an inclusive upper bound, you can use rni: (rni -5 5) is equivalent to -5 ..= 5.

Because rn is such a common iterator, we provide the forn macro to make it more convenient to use. (forn should be read as a contraction of for rn, in the same way that defn is a contraction of def fn.)

(forn (digit 0 10)
  (prn digit))

; ...is equivalent to...

(for digit in (rn 0 10)
  (prn digit))

Double-Ended Iterators

Some iterators are "double-ended": items can be produced both from their back and from their front. For example, array and string iterators are double-ended. You can query whether an iterator is double-ended using the iter-double-ended? function, and you can produce items from the back of a double-ended iterator using iter-next-back!.

rev takes a double-ended iterable and reverses it, treating its back as its front and its front as its back.

Exact-Size Iterators

Some iterators know more about their length than others do. For example, a rn iterator knows the exact number of items it will return, but a lines iterator has no way to predict its item count in advance.

We don't provide an equivalent to Rust's size_hint(), because it wouldn't be useful. GameLisp doesn't provide any way for you to manipulate the capacity of its collections or reserve memory in advance.

Instead, the len function can accept an iterator as its argument. If that iterator knows its exact length, it returns an integer; if it knows itself to be infinite, it returns the symbol infinite; and otherwise it returns the symbol unknown.

(prn (len (rn 5))) ; prints 5
(prn (len (repeat #t))) ; prints infinite
(prn (len (split text \space))) ; prints unknown

There's nothing to prevent an array or string from being mutated during iteration (although this is strongly discouraged). This means that array and string iterators do not know their exact size. Pushing or popping from the end of a deque during iteration will work as expected, but pushing or popping from the start may cause the iterator to behave unpredictably.

Splaying

We've previously mentioned that you can use .., an abbreviation for splay, to pass all of an array's elements to the array constructor.

(let triad '(x y z))
(prn (arr 'a 'b 'c ..triad 1 2 3)) ; prints (a b c x y z 1 2 3)

The splay operator is actually much more powerful than this. It will accept any iterable, and pass all of its items as arguments to any function call.

This means that there's no need for GameLisp to have a collect function: you can just splay an iterator while calling arr, str, tab, or any other constructor function.

(prn (str ..(take 5 (repeat \A)))) ; prints AAAAA

There's also no need for GameLisp to have apply: you can just splay an array as a function's last argument instead.

If you want to take the sum of an array of numbers, there's no need to look up the API for fold. Addition is a variadic function, so you can just call (+ ..the-array). The smallest element of a collection is (min ..coll). To test whether an array of numbers is sorted, call (<= ..numbers). Appending the contents of one array onto another is just (push! arr0 ..arr1).

Indexing

Arrays, strings, objects and classes are normally indexed using an integer or a symbol. However, it's possible to index them using an iterable instead.

This returns a new iterator which takes each item in turn from the original iterable, indexes the collection using [coll item], and produces the resulting element.

In effect, [coll iterable] is equivalent to (map (fn1 [coll _]) iterable).

; re-order an array's elements
(let shuf (arr ..[src-arr '(1 3 0 2)]))

; equivalent to...
(let shuf (arr [src-arr 1] [src-arr 3] [src-arr 0] [src-arr 2]))

; swizzle an object's fields
(let offset (Vec3 x y z))
(let swizzled (Vec3 ..[offset '(y z x)]))

; discard every second character in a string
(let text "You're filled with DETERMINATION.")
(prn ..[text (step-by 2 (rn (len text)))]) ; prints Yur ildwt EEMNTO.

; use multiple object fields as consecutive function arguments
(draw-sprite spr ..[very-long-coordinates-name '(x y)])

; equivalent to...
(draw-sprite spr [very-long-coordinates-name 'x] [very-long-coordinates-name 'y])

Note that tables do not support this kind of indexing. This is because table keys can belong to any primitive type, including iterators, arrays, strings, and so on. If you were to call [table '(0 0 0)], it would be ambiguous whether you were trying to access the key (0 0 0) once, or trying to access the key 0 three times.

Arrows

Creating a complicated iterator might involve several deeply-nested function calls.

As ever, the arrow macros are the best way to flatten out a deep call hierarchy. ->> is often a good choice when working with iterators, because iterator adapters usually expect an iterator or iterable as their last argument.

(->> my-array (step-by 3) (map (fn1 (+ _ 10))) enumerate)

; ...is equivalent to...

(enumerate (map (fn1 (+ _ 10)) (step-by 3 my-array)))

The arrow macros include special handling for the splay operator. If you prefix one of the arrowed function calls with .., then the result of the previous function will be splayed.

(->> my-array (step-by 3) (map abs) (filter (fn1 (< _ 10))) ..arr)

; ... is equivalent to...

(arr ..(filter (fn1 (< _ 10)) (map abs (step-by 3 my-array))))

Coroutines

A coroutine is a function which can be paused, and later on resumed from where it left off.

Coroutines are defined using the yield special form. When yield is encountered within a function, that function pauses its execution, and control flow returns to the caller. Later, coro-run can be used to resume the coroutine, which causes execution to restart from the yield form.

(defn example ()
  (pr "first ")
  (yield)
  (prn "third"))

; invoking a function which contains a (yield) does not start its execution.
; instead, it returns a coroutine which is paused at the start of the
; function's body.
(let coroutine (example))

(coro-run coroutine) ; executes up until the yield
(pr "second ")
(coro-run coroutine) ; executes from the yield to the end of the function

; the above code prints: first second third

Coroutines can pass values back and forth to their caller when they are paused and resumed. (yield x) causes the value x to be returned from the (coro-run ...) call. (coro-run coroutine y) causes the value y to be returned from the (yield) call. In both cases, when no value is specified it defaults to #n.

; this coroutine returns values to its caller. note that coroutines can
; receive arguments, just like a normal function call
(defn increment-forever (n)
  (loop
    (yield n)
    (inc! n)))

(let co (increment-forever 100))

(prn (coro-run co)) ; prints 100
(prn (coro-run co)) ; prints 101
(prn (coro-run co)) ; prints 102

; this coroutine receives values from its caller
(defn overly-elaborate-prn ()
  (loop
    (prn (yield))))

(let co (overly-elaborate-prn))
(coro-run co) ; run until the first (yield)...

(coro-run co 'alpha) ; the coroutine prints alpha
(coro-run co 'beta) ; the coroutine prints beta
(coro-run co 'gamma) ; the coroutine prints gamma

fn-yields? will tell you whether or not a function will create a coroutine when called.

Life-Cycle of a Coroutine

The coro-state function returns a symbol describing the current state of a coroutine: newborn, running, paused, finished or poisoned.

Coroutine life-cycle diagram

When you call a function which has at least one yield form somewhere in its body, it will return a newborn coroutine. You can execute it with coro-run; a currently-executing coroutine is in the running state. When it encounters a yield form, it will become paused, meaning it can be resumed again with coro-run. When a running coroutine returns, it will transition to the finished state, which is the end of its life-cycle.

If an error bubbles through a coroutine while it's executing, we assume that it's been left in a disorderly state. Its state is set to poisoned, and any attempt to resume it will trigger an error. This is analogous to how Rust's Mutex type works.

Coroutines and Iteration

Coroutines are iterable. A coroutine iterator will repeatedly call (coro-run the-coro) and produce each value which the coroutine yields.

This can be used to implement custom iterators:

; a coroutine-based implementation of the `lines` function. copies 
; a string and then splits it into individual lines, yielding one 
; line at a time. once there are no lines left, it returns.
(defn lines (source-str)
  (let st (clone source-str))
  (while (> (len st) 0)
    (let pos (position st \newline))
    (cond
      ((nil? pos)
        (yield st)
        (break))
      (else
      	(let line (remove! st : pos))
      	(pop-start! st)
        (yield line)))))

(prn (arr ..(lines "aaa\nbbb\nccc"))) ; prints ("aaa" "bbb" "ccc")

Stackful and Stackless Coroutines

In languages like Lua and Ruby, coroutines are "stackful". Each coroutine allocates its own call-stack, and it can yield from arbitrarily deep within that call-stack.

On the other hand, GameLisp's coroutines follow the model set by Rust, Python, C# and C++. Our coroutines are "stackless": they only store enough data to pause a single call-frame, so it's not possible for a coroutine to call a non-coroutine function and yield from inside it. You can only yield from the body of the coroutine function itself.

The primary reason for this is that it's much more efficient. Each and every Ruby coroutine (known as a Fiber) allocates a four-kilobyte, fixed-size callstack. In contrast, a typical GameLisp coroutine will only allocate 100 to 300 bytes of data - about one-twentieth of the memory burden!

The other reason to prefer stackless coroutines is that they can be nested almost as straightforwardly as stackful coroutines. The yield-from macro will loop through an iterable, repeatedly yielding each of its results until it's finished. When the iterable is a coroutine, this is roughly equivalent to calling a function and yielding from inside it.

; a coroutine which keeps yielding until some event is triggered,
; and then returns
(defn wait-until-trigger (trigger-name)
  (until (trigger-set? trigger-name)
    (yield)))

; a coroutine which controls the behaviour of an entity. the coroutine is
; resumed once per frame. it will do nothing until the 'can-move event 
; has been triggered, and then it will horizontally slide towards the 
; target x coordinate, bit by bit, until it has been reached.
(defn move-to (self x-target)
  (yield-from (wait-until-trigger 'can-move))

  (until (= [self 'x] x-target)
    (seek! [self 'x] x-target 1.5)
    (yield)))

Why Coroutines?

Coroutines can be thought of as a form of cooperative multitasking. They behave like operating-system threads, but rather than being interrupted at unpredictable intervals by the scheduler, they're allowed to execute for as long as they please. The coroutine itself decides when to manually end its time-slice and yield control back to the "scheduler" (in this case, the function which invoked coro-run).

This type of control flow is a natural fit for game programming. Game worlds tend to be filled with entities which perform complicated behaviours, splitting their actions into tiny incremental "steps". When the entity isn't doing anything too elaborate, it's easy to model their behaviour as a simple function which is called once per frame - but when you want to do a few different things in sequence, "action A followed by action B followed by action C", then coroutines are usually a much better choice.

Consider a cutscene script controller. We want this cutscene to show a dialogue bubble until the player dismisses it, then have the main character walk to the right until they reach a waypoint, then pause dramatically for three seconds, then show another dialogue bubble. Cramming all of this state into a single event-handler function is a real challenge:

(defn step-handler (self)
  (match [self 'current-state]
    ('start
      (= [self 'bubble] (speak self "You don't understand! I just have..."))
      (= [self 'current-state] 'bubble-0))

    ('bubble-0
      (when (bubble-finished? [self 'bubble])
        (= [self 'current-state] 'walking-to-waypoint)))

    ('walking-to-waypoint
      (step-towards-point self (waypoint 'dramatic-pause))
      (when (at-waypoint? self (waypoint 'dramatic-pause))
        (start-sound 'howling-wind)
        (= [self 'pause-timer] 3.0)
        (= [self 'current-state] 'pausing)))

    ('pausing
      (dec! [self 'pause-timer] :dt)
      (when (<= [self 'pause-timer] 0.0)
        (= [self 'bubble] (speak self "...too many Incredibly Deep Feelings."))
        (= [self 'current-state] 'bubble-1)))

    ('bubble-1
      (when (bubble-finished? [self 'bubble])
        ; leave the viewer to process the scene's breathtaking emotional pathos
        (= [self 'current-state] 'finished)))))

The equivalent coroutine is a beauty:

(defn run-cutscene (self)
  (yield-from (speak self "You don't understand! I just have..."))
  (yield-from (walk-to-point self (waypoint 'dramatic-pause)))
  (start-sound 'howling-wind)
  (yield-from (wait-secs 3.0))
  (yield-from (speak self "...too many Incredibly Deep Feelings.")))

All of that state which we had to manually store elsewhere is now implicit in the coroutine. The child functions would be simpler, too: the coroutine walk-to-point is likely to be much easier to implement, compared to the function step-towards-point. Our coroutine even has slightly better performance! Previously we were calling (waypoint) every frame because it would have been too much effort to cache it, but the coroutine makes it obvious that rechecking the waypoint every frame is actually the more-expensive option.

Cutscene scripting is usually such a nightmare that many smaller game projects either give up hope of doing anything interesting with it, or come up with some hacky, limited, data-driven cutscene control library. But with the incredible power of coroutines, it's all just code, and you can make your cutscenes as complicated and emotional as you like! Try not to make your players cry too much.

Coroutines are particularly powerful when combined with explicit state machines. We'll explore the possibilities in more depth when we discuss object-oriented programming.

Patterns

Checking whether a value has a particular shape, and extracting other values from its interior, is a fundamental operation. In languages which don't have first-class support for destructuring, this operation tends to be unnecessarily difficult. For example, binding array elements to local variables in C is more verbose than it ought to be:

assert(array_len >= 3);
float x = array_ptr[0];
float y = array_ptr[1];
float z = array_ptr[2];

Likewise for iterating over an array of 3D points in Lua:

for _, point in ipairs(points) do
    local x, y, z = point.x, point.y, point.z
    -- ...
end

GameLisp includes destructuring facilities which are as powerful as Rust's, with a number of changes and additions to reflect the fact that GameLisp is dynamically-typed rather than statically-typed.

Basic Patterns

A pattern may appear anywhere that you might create a new variable binding: the left-hand-side of a let, def, field, or with-global form, a function parameter, or the item in a for loop.

A pattern is a form, but it's not evaluated or macro-expanded in the normal way. Patterns have their own special evaluation rules.

When a pattern is a symbol, it matches any value and binds it to that symbol.

(let x-doubled (* x 2))

The symbol _ is special: it matches any value and discards it.

(forn (_ 100)
  (do-something-one-hundred-times))

(let _ 100)
(prn _) ; an error: the symbol _ is not bound to a variable

Self-evaluating forms, like strings, quoted forms, tables, empty arrays, and numbers, only match a value which is eq? to the pattern. The value is then discarded.

; both of these succeed, but without creating any bindings
(let 10 (+ 5 5)) 
(let #((a b)) (tab ('a 'b)))

Unlike Rust, patterns in let bindings are not required to be infallible. Any pattern can fail to match. If a mismatch occurs in let, def, field, with-global or for, it's an error.

(let "xyz" (arr 'x 'y 'z)) ; an error
(let 'a-symbol (tab ('a 'b))) ; an error

You can deal with fallible bindings using the match, when-let and matches? macros.

  • match checks a value against a series of patterns and executes some code for the first pattern which successfully matches the value, returning #n if no patterns match.
  • when-let does the same for a single pattern.
  • matches? returns #t when a value matches a pattern, or #f when it's a mismatch.

(match (len ar)
  (0
    "empty array")
  (1
    "array with a single element")
  (n
    "array with {n} elements"))

A pattern isn't always just a single form; it's sometimes described by a series of consecutive forms. For example, wherever you would write a pattern, you can instead write x at pat. This processes pat as normal, but also binds the additional variable x, which is set to the pattern's input value. (This is similar to Rust's x @ pat syntax.)

Predicates

A "predicate" is a function which asks a question. For example, the functions int?, >=, eq? and fn-yields? are all predicates.

Any pattern can test an arbitrary predicate using the syntax pat : pred?. When pat matches the input value, the predicate function pred? is invoked - if the result is false, then the match becomes a mismatch.

Note that the : is whitespace-sensitive, so pat: pred? would be invalid syntax.

(match arg
  (i : int?
    "the integer {i}")
  (f : flo?
    "the floating-point number {f}")
  (val
    (bail "expected a number but received {(type-of val)}")))

(let result : num? (calculate-number))

(when-let f : flo? result
  (prn "calculated the floating-point number {f}")
  (prn (matches? result _ : int?))) ; prints #f

When pred? is a symbol, the predicate is invoked on the input value being tested, (pred? input). Under those circumstances, to avoid confusion, pat must be a symbol or _.

When pred? is not a symbol, it's evaluated as an arbitrary form which has access to all of the pattern's bindings. If the evaluation result is false, the pattern is a mismatch. The form will usually be a function call which inspects one or more of the pattern's variables.

; a predicate can be used like a match guard in Rust
(match (calculate-x)
  (x : (== x 0)
    "zero")
  (x : (> x 0)
    "the positive number {x}")
  (x
    "the negative number {x}"))

You can test arbitrarily complex predicates by defining your own functions.

(let-fn u8? (n)
  (and (int? n) (<= 0 n 255)))

(defn rgb (r : u8?, g : u8?, b : u8?)
  (bitor (bitshl r 16) (bitshl g 8) b))

(ensure (== (rgb 0xb7 0x41 0x0e) 0xb7410e))

Predicates can perform Boolean operations using the forms (and pred? ...), (or pred? ...) and (not pred?). These forms can be arbitrarily nested inside one another.

(match f
  (f : (and flo? (or inf? nan?))
    "a weird float {f}")
  (f : flo?
    "a normal float {f}")
  (_
    "not a float"))

(when-let sym : (and sym? has-global?) input
  (let g (global sym))
  (prn "the input is a symbol bound to the global {g}"))

Predicates are not a static type system! When you place a predicate on a variable's definition, it only checks the variable's initial value - if the variable is mutated, the predicate may no longer hold.

(def name : str? "global string")
(= name 'assigned-symbol)
(prn (str? name)) ; prints #f

Arrays

An array pattern will match an array value with exactly the same number of arguments.

(let (one two three) '(1 2 3))
(let (one two) '(1 2 3 4 5)) ; an error

Each element of the array pattern is, itself, a pattern.

(let ((a b) (c d)) '((1 2) (3 4)))

Complex patterns should be separated using commas to improve readability.

(defn constant-fold (form)
  (match form
    ((callee : (has? foldable-ops callee), a : num?, b : num?)
      ((global callee) a b))
    (form
      form)))

(prn (constant-fold '(+ 2 3))) ; prints 5
(prn (constant-fold '(+ var-name 3))) ; prints (+ var-name 3)

Predicates can access variables defined for earlier array elements - not just their current element.

(let (one, two : (> two one)) '(1 2))

In forms like let and def, when there is no initializer value, the pattern will automatically succeed and set all of its bindings to #n. This is a convenient way to declare several uninitialized local variables at once.

(let (width height depth))
(prn width height depth) ; prints #n #n #n

Optional Patterns

(? pat) is an "optional pattern" - it represents an element which may or may not exist. In an array pattern, if that element is missing (because the array is too short to have an element at that index), the pattern unconditionally succeeds and all of its bindings are initialized to #n.

(let (a (? b) (? c)) '(1 2))
(prn a b c) ; prints 1 2 #n

; the predicate succeeds, even though (int? #n) is false
(let (d : int?, (? e) : int?) '(4)) 
(prn d e) ; prints 4 #n

An optional array element may specify a default value, which is used in place of #n when the element is missing. The syntax is (? pat init). When the pattern has a predicate, (? pat init) : pred?, the default value is tested by the predicate.

(let (x y (? z 0.0)) '(5.0 3.5))
(prn x y z) ; prints 5.0 3.5 0.0

While a default value is being evaluated, any bindings from earlier array elements will be in scope and initialized.

(let (small (? big (* small 10))) '(5))
(prn small big) ; prints 5 50

Rest Patterns

..pat is a "rest pattern" - it can be used to capture zero or more contiguous array elements which were not captured by any other patterns. Each element is tested against pat (including calling pat's predicate, if it has one, for each element individually). The rest pattern only matches when all of the individual elements match. Each variable binding in pat is bound to a newly-allocated array which contains one value for each element.

(let pairs '((a 1) (b 2) (c 3)))

(let (..(letters : sym?, numbers : int?)) pairs)

(prn letters numbers) ; prints (a b c) (1 2 3)

; the most common use of rest patterns is to unconditionally collect
; part of an array into a variable.
(let (first, second, ..rest : int?) '(0 1 2 3 4))
(prn first second rest) ; prints 0 1 (2 3 4)

; the .._ pattern can be used to discard any number of array elements.
; in that case, no allocation will be performed.
(let (first .._ last) '(0 1 2 3 4))
(prn first last) ; prints 0 4

The order of optional and rest patterns is quite flexible. Any of the following would be valid array patterns:

(a (? b) (? c))
(a (? b) ..c)
(..a b c)
(a ..b c)

Functions

Function parameter lists are just array patterns, where the input array contains all of the arguments to the function call. This means that functions and macros can take advantage of the full power of predicates, optional patterns, and rest patterns.

(defn draw-text (text : str?, x : num?, y : num?, 
                 (? font simple-font) : (is? font 'Font))
  ...)

(defn process (first ..rest)
  ...

  (unless (empty? rest)
    (process ..rest)))

Array patterns can be used to emulate function overloading. For example, this function expects a different parameter list depending on whether its first argument is a deque or a table:

(defn search (..args)
  (match args

    ((haystack : deque?, needle)
      (any? (fn1 (eq? _ needle)) haystack))

    ((haystack : tab?, key, value)
      (eq? [haystack key] value))

    (args (bail "invalid argument list {args}"))))

GameLisp can optimize functions more aggressively when their parameter list follows all of these rules:

  • ..rest is either the last parameter, or absent.
  • For (? opt) and ..rest parameters, the pattern itself is _ or a symbol.
  • For (? opt init) parameters, init is either self-evaluating (like ()) or quoted (like '(6 7)).

or Patterns

or isn't only useful in predicates. You can use an (or pat ...) form wherever you would use any other pattern. It matches, and binds, the first matching child pattern. If none of the child patterns match, the or pattern is a mismatch.

You can think of or as being like | in Rust patterns.

; match on a valid (+), (-), (*) or (/) form. (/) requires at least
; one argument, but the others can be called with zero arguments.
(match form
  ((or ((or '+ '- '*) ..rest)
       ('/ first ..rest))
    ...))

All possible variable bindings from all of the child forms will be bound by the or pattern. If a variable isn't present in the sub-pattern which matches, that variable will still be bound, but it will be initialized to #n.

; search for an item in a deque, which can be defined either by a predicate
; callback, or by an argument which should be (eq?) to the item.
(defn search (haystack : deque?, (or is-needle? : callable?, needle-item))
  (for (i item) in (enumerate haystack)
    (cond
      ((nil? is-needle?)
        (when (eq? needle-item item)
          (return i)))
      (else
        (when (is-needle? item)
          (return i)))))
  #n)

Indexing

When a pattern is an access form - in other words, when a pattern is enclosed in square brackets - it will attempt to index its value. The pattern matches if all of the indexes are present.

The access form's children are usually symbol patterns, in which case each indexed value is bound to the corresponding name.

(let [x y z] 3d-vector)
(let length (cbrt (+ (* x x) (* y y) (* z z))))

(let [height : int?, weight : flo?] physical-properties)

Each child form may also be an array (index pat), where index is a literal value (not necessarily a symbol!) used to index the collection, and pat is a pattern which is matched against the resulting value.

(let [(x src-x) (y src-y)] src-2d-vector)
(let [(x dst-x) (y dst-y)] dst-2d-vector)
(prn "from ({src-x}, {src-y}) to ({dst-x}, {dst-y})")

(let [(0 zeroth : flo?) (500 five-hundredth : flo?)] huge-array)

? and .. patterns work in much the same way that they do for arrays. The .. pattern will collect all remaining key/value pairs into a table; it only matches input collections which are, themselves, tables. Any predicates are tested against the value, rather than the key.

(let table #((a 1) (b 2) (d 4) (e 5)))

(let [a (? b) (? c) ..rest : int?] table)
(prn a b c) ; prints 1 2 #n
(prn rest) ; prints #((d 4) (e 5)), not necessarily in that order

File Formats

If you have some custom data files which your game should store - for example, the file format used by your level editor - then you should consider storing them in the GameLisp text format.

GameLisp already has powerful facilities for parsing and manipulating its own data-types, but patterns take things to the next level. For example, a function to validate the level file-format for a simple role-playing game might look like this:

(defn level-valid? (lvl)
  (matches? lvl [
    name : str?
    background-music : sym?
    (tiles (..(x : int?, y : int?)))
    (entities (..entity : entity-spec-valid?))
  ]))

(defn entity-spec-valid? (entity)
  (matches? entity [
    class-name : sym?
    (coords (x : int?, y : int?))
    (? init-args ()) : arr?
  ]))

; the (level-valid?) function would match a level file with these contents
#(
	(name "tower-exterior")
	(background-music aria)
	(tiles ((0 5) (3 1) (1 1) (1 1) #|and so on|#))
	(entities (
		#((class-name TowerGuard) (coords (70 95)) (init-args (alert)))
		#((class-name Portcullis) (coords (70 60)))
	))
)

; contrast similar code written in a more imperative style...
(defn entity-spec-valid? (entity)
  (and
    (sym? [entity (? 'class-name)])
    (has? entity 'coords)
    (do
      (let coords [entity 'coords])
      (and
        (arr? coords)
        (== (len coords) 2)
        (do
          (let x [coords 0], y [coords 1])
          (and (int? x) (int? y)))))
    (or
      (not (has? entity 'init-args))
      (arr? [entity 'init-args]))))

(defn level-valid? (lvl)
  (and
    (str? [lvl (? 'name)])
    (sym? [lvl (? 'background-music)])
    (has? lvl 'tiles)
    (block tiles-checker
      (for tile in [lvl 'tiles]
        (unless (and (arr? tile) (== (len tile) 2) (int? [tile 0]) (int? [tile 1]))
          (finish-block tiles-checker #f)))
      #t)
    (has? lvl 'entities)
    (all? entity-spec-valid? [lvl 'entities])))

Patterns are concise and powerful, but they should be used cautiously - it can be difficult to fully understand the behaviour of a complex pattern. If you find that a pattern you've written isn't self-explanatory at a glance, consider refactoring it into multiple simpler patterns, even if that makes your code more verbose.

Cheat Sheet

PatternMeaning
_Match, and discard, any value
"hello"Match, and discard, the string "hello"
'worldMatch, and discard, the symbol world
xMatch any value and bind it to x
x : pred?Match a value for which (pred? x) is true, and bind it to x
pat : (form y z)As for pat, but only matches when (form y z) is true
x at patAs for pat, but additionally binds the input value to x
x at pat : pred?Combines the previous two cases
(or pat1 pat2)Match either pat1 or pat2
(pat1 pat2)Match an array of two elements
(pat1 (? pat2))Match an array of one or two elements
(pat1 ..pat2)Match an array of one or more elements
[x]Match a collection with the field 'x, binding it to x
[(x pat)]As for [x], but the value is matched against pat

Object-Oriented Programming

Game code is naturally object-oriented. A typical game codebase might contain:

  • A central "engine", mostly implemented in a native language, to handle anything highly performance-sensitive (such as physics and rendering) and anything which is inherently global (such as key bindings and saved games).

  • A very large number of unique "entity" types, mostly implemented in a scripting language, each representing a different kind of in-game object.

    • The engine behaves as a "server" and the entities behave as its "clients": registering themselves to participate in the physics system, emitting drawing commands, serializing themselves when the game is saved, and so on.

    • Entities are sometimes data-driven (for example, an enemy in an action-adventure game might be partially defined by its total health and a list of elemental weaknesses), but defining a new entity usually involves at least some scripting (for example, each enemy would have an AI script to control its behaviour in combat).

Entity definitions usually make up the lion's share of any game's codebase, so providing a great environment for defining entities was my number one priority while designing GameLisp. After experimenting with a few alternatives, I eventually settled on a traditional, class‑based object‑oriented programming (OOP) system.

This may surprise some readers - after all, OOP has been steadily going out of fashion for the last decade or two, particularly in game development. This is because OOP has a few well-known flaws which can make it architecturally awkward. GameLisp has a unique take on OOP which tries to minimize those downsides, while still taking advantage of its convenience and intuitiveness. It's quite different from OOP as seen in languages like C++, Java and Python.

Fundamentals

GameLisp's primitive types include objects and classes. Each class describes a type of object, and each object is an individual instance of a class.

You can define a new class using the macros defclass, let-class and class. They work in the same way as defn, let-fn and fn, respectively.

(defclass Bomb
  (const initial-time 30.0)
  (field timer @initial-time)

  (meth tick (delta-time)
    (dec! @timer delta-time)
    (when (<= @timer 0.0)
      (prn "BOOM!"))))

Classes are full-fledged GameLisp values. You can store them in an array, bind them to a local variable, pass them as a function argument, and so on. defclass just evaluates a (class ...) form and binds it to a global variable.

(let Renamed Bomb)
(ensure (class? Bomb))
(ensure (same? Bomb Renamed))

(prn Bomb) ; prints #<class:Bomb>
(del-global! 'Bomb)
(prn Bomb) ; an error

Classes are always immutable. There's no way to modify a class after you've defined it.

As demonstrated above, classes are described using a sequence of "clause" forms. The most important clauses are (field ...), (const ...) and (meth ...).

Fields and Constants

A (field name) clause defines an object field. Each field reserves enough space inside the object to store a single GameLisp value, of any type. You can optionally provide an initializer form, which will be evaluated during object initialization. All fields are mutable.

A (const name init) clause defines an associated constant. Constants take up no storage in the object. Their init form is evaluated when the class is defined, and it can refer to previous constants in the same class.

(const width 40)
(const height 60)
(const area (* @width @height))

Fields and constants share the same namespace, and they're public by default. You can access a field or constant on a particular object using the [] syntax, just as you might access a table field.

(defn describe-bomb (bomb)
  (prn "initial time: " [bomb 'initial-time])
  (prn "current time: " [bomb 'timer]))

(defn reset-bomb (bomb)
  (= [bomb 'timer] [bomb 'initial-time]))

You can also use [] to look up the value of a constant in a class.

(prn "initial time for all bombs: " [Bomb 'initial-time])

Methods

A (meth name (args...) body...) clause defines a method: a function which "acts on" a particular object. Unlike Rust, the self argument is defined implicitly rather than explicitly, so you would write (meth a (b) ...) rather than (meth a (self b) ...).

Methods share the same namespace as fields and constants, and like fields and constants, they're public by default.

To invoke a method on an object, use the (.name obj args...) syntax.

(defn tick-all-bombs (bombs delta-time)
  (for bomb in bombs
    (.tick bomb delta-time)))

That same syntax can be used to invoke a function which is stored in a field or constant. In that case, the function isn't considered to be a method, so it won't receive a self parameter and it won't be able to access any object fields.

(defclass Static
  (const print-description (fn () 
    (prn "your hair is standing on end!"))))

(.print-description Static)

@ Forms

@name is an abbreviation for (atsign name).

@ is only meaningful within the body of a method, or within the initializer form of a field or constant. Broadly speaking, it's used to access some property of the current object, similar to self in Rust.

@width means "access the field or constant named width on the current object".

(@run a b) means "invoke the method run on the current object, with arguments a and b".

(defclass Paintbrush
  (field red)
  (field green)
  (field blue)

  (meth rgb ()
    (arr @red @green @blue))

  (meth print-color ()
    (prn (@rgb)))

  (meth make-grayscale! ()
    (let avg (/ (+ @red @green @blue) 3))
    (= @red avg, @green avg, @blue avg)))

There are a few special cases. @self returns a reference to the current object, @class is the current object's class, and @class-name is the name of the current object's class as a symbol.

First-Class Functions

Methods can't be passed around as first-class values. If you try to access a method as you would access a field or constant, it's an error.

(defclass WidgetValidator
  (const threshold 10.0)

  (meth widget-valid? (widget)
    (>= [widget 'validity] @threshold))

  (meth validate (widgets)
    (all? @widget-valid? widgets))) ; an error

However, forms like @self and @width work by referring to a hidden local variable, which is eligible to be captured using fn. Therefore, creating a first-class function which delegates to a method is straightforward.

(meth validate (widgets)
  ; invoke the widget-valid? method on @self, passing in each widget
  (all? (fn1 (@widget-valid? _)) widgets))

Initialization

To instantiate a new object, simply call a class, in the same way that you would call a function.

(let bomb (Bomb))

(prn bomb) ; prints #<obj:Bomb>
(ensure (obj? bomb))
(ensure (same? (class-of bomb) Bomb))

You can pass in parameters to the function call. Those parameters will be passed to a special initializer method, which can be defined using an (init ...) clause.

(defclass Refinery
  (field fuel-limit)
  (field fuel)

  (init (@fuel-limit)
    (= @fuel (/ @fuel-limit 5))))

(let refinery (Refinery 1000))
(prn [refinery 'fuel]) ; prints 200

There will often be a one-to-one relationship between your class's fields and the parameters to its initializer method. To save you from typing out the same name several times, we provide special field-initialization syntax:

(defclass AppleTree
  (field fruit-count)
  (field health (* @fruit-count 20))
  (field planted-on-date)

  (init (@fruit-count @planted-on-date))
    (prn "created an apple tree with {@fruit-count} fruit"))

(let tree (AppleTree 6 (in-game-date)))

The initialization method may prefix any of its parameter names with @, in which case the class must have a field which shares the same name. At the start of the initialization method, each field is initialized in the order that they were defined, emitting an (= @name name) form for any parameters prefixed with @.

In other words, the above AppleTree class definition is equivalent to:

(defclass AppleTree
  (field fruit-count)
  (field health)
  (field planted-on-date)

  (init (fruit-count planted-on-date)
    (= @fruit-count fruit-count)
    (= @health (* @fruit-count 20))
    (= @planted-on-date planted-on-date)
    (prn "created an apple tree with {@fruit-count} fruit")))

Normal meth clauses may also have @-parameters. In that case, they just emit a (= @name name) form at the start of their body, in no particular order.

(meth on-health-change (@health)
  (when (< @health 0)
    (prn "the tree withers away!")))

; ...is equivalent to...

(meth on-health-change (health)
  (= @health health)
  (when (< @health 0)
    (prn "the tree withers away!")))

Finalization

The obj-kill! function will execute an object's finalizer method (defined using a (fini ...) clause), and then permanently delete its storage. Trying to access a field or call a method on a killed object is an error.

(defclass Mandrake
  (field coords)

  ; ...

  (fini
    (for entity in (query-entities 'within-distance 50 @self)
      (.hit 150 'necrotic))))

(let mandrake (Mandrake spawn-coords))
(obj-kill! mandrake)
(prn [mandrake 'coords]) ; an error

Note that the fini method will not be called if an object is simply garbage-collected, so it can't be used for RAII-style resource cleanup. We'll discuss some alternatives to RAII in the Errors chapter.

Privacy

If you read the textbooks, they will tell you that information hiding is one of the pillars of object-oriented programming. Languages like C# tend to make all class members private by default - not only is it impossible to access them from a different project, but they can't even be accessed by a different class within the same source file!

In practice, for day-to-day code, it's easy to waste too much effort carefully hiding fields and methods from yourself and your colleagues. I find that a game codebase can tolerate a lot of "unnecessarily public" fields and methods, as long as you choose sensible names for those few bindings which are intended to be accessed from outside the class. This is why fields, constants and methods are public by default in GameLisp.

That being said, privacy is occasionally useful. If you're writing a mixin or classmacro which will be integrated into many different classes, or if you're writing a class for a library which is intended to be used by strangers, then putting lots of names in your object's public namespace becomes significantly more risky.

In the macros chapter, we discussed a technique for avoiding name collisions in macros: auto‑gensym. Classes have access to the same technique. When a symbol anywhere in a class is suffixed with #, each occurrence of that symbol will be replaced with the same gensym. This makes it impossible to refer to that name from outside the class, but it can still be accessed by other objects of the same class.

(defclass SecretivePoint
  (field x#)
  (field y#)

  (init (@x# @y#))

  (meth op-eq? (other)
    (and (== @x# [other 'x#]) (== @y# [other 'y#]))))

(let point (SecretivePoint 20 20))
(prn [point 'x#]) ; an error

Properties

If you have one entity which advertises its coordinates as a field [et 'coords], and a more complex entity which provides the same information as a method (.coords et), writing client code to deal with both possibilities can be irritating.

On the other hand, when defining a class, you don't want to waste a lot of effort writing useless methods which only return a field's value.

(defclass GameLispOrJava?
  (field width# 5)
  (field height# 5)

  (meth width ()
    @width#)

  (meth height ()
    @height#))

GameLisp borrows a leaf from C#'s book by allowing you to define a pair of methods which behave like a field. The "getter" method is called when the field is accessed, and the "setter" method is called when a new value is assigned to the field.

(defclass GameLispOrCSharp?
  (prop width 5 (get))
  (prop height 5 (get)))

Each property has a "backing field" which stores a single value. The class above would define one field named width:field and another named height:field. The empty (get) forms simply return the backing field's current value. You could also define an empty (set) form, which just assigns its argument to the backing field.

Of course, (get ...) and (set ...) can also be defined with a method body. Within those methods, it's possible to refer to the backing field as @field:

; a creature whose apparent position differs from its true position
(defclass DisplacerBeast
  (prop coords
    (get
      (let (x y) @field)
      (arr (- x 10) (- y 10)))
    (set (arg)
      (let (x y) arg)
      (= @field (arr (+ x 10) (+ y 10)))))

  (meth print-description ()
    (prn "my coords are " @coords))

  (meth print-secret-description ()
    (prn "my actual coords are " @coords:field)))

Just like fields and constants, properties can be initialized automatically, using the syntax (prop name init ...). The initial value is assigned to the backing field directly; the setter is not invoked.

Properties are more complicated than fields, so you should avoid using them when they're not necessary. Unless you really need to prevent assignment for some reason, the classes above should simply be written as:

(defclass GameLisp
  (field width 5)
  (field height 5))

Arrows

As usual, the arrow macros can be used to flatten out deeply-nested function calls and field accesses. The arrow macros include special handling for @name and .name forms.

(-> rect .coords (.offset-by 10 10) @to-local ['x])

; ...is equivalent to...

[(@to-local (.offset-by (.coords rect) 10 10)) 'x]

Note the resemblance to Rust's syntax. In Rust, the above would be written as:


#![allow(unused_variables)]
fn main() {
self.to_local(rect.coords.offset_by(10, 10)).x
}

Classmacros

Class definitions can become repetitive. For example, every entity in your game might have an on-step method with the same set of parameters:

(defclass MetalWall
  (meth on-step (controller delta-time)
    ...))

(defclass LaserSword
  (meth on-step (controller delta-time)
    ...))

; i've only typed that boilerplate twice and i'm already tired of it

You can use defclassmacro to define a macro which will be invoked in place of a class clause. For example, in the above case, we might define a step classmacro:

(defclassmacro step (..body)
  `(meth on-step (controller delta-time)
    ..body))

(defclass MetalWall
  (step
    ...))

(defclass LaserSword
  (step
    ...))

Classmacros can use splice to emit multiple clauses from a single macro invocation. On the other hand, if a classmacro decides that it doesn't want to emit any clauses, it can return #n. Nil clauses are silently ignored.

Classmacros are powerful. For example, you could define a small coro-step classmacro which resumes a coroutine every step.

(defclassmacro coro-step (..body)
  `(splice
    (field coro-name#)
    (field setter-name#)

    (meth on-step (controller cur-delta-time#)
      (when (or (nil? coro-name#) (not (eq? (coro-state coro-name#) 'paused))) 
        (let delta-time cur-delta-time#)
        (= coro-name# ((fn () ..body)))
        (ensure (coro? coro-name#))
        (= setter-name# (fn (dt) (= delta-time dt))))

      (setter-name# cur-delta-time#)
      (coro-run coro-name#))))

(defclass LaserSword
  (coro-step
    (prn "vrumm")
    (yield)
    (prn "VWOM")
    (yield)
    (prn "ñommmm")))

Aside: Why Not Prototypes?

Several popular scripting languages (namely Lua, JavaScript, and their derivatives) have an object system very different from GameLisp's. In those languages, there is no distinction between an object and a table, or between a method and a table field. Tables may delegate field accesses to another table, which establishes a de facto single inheritance hierarchy.

Prototype-based object systems have the advantage of being extremely simple and easy to learn. Unfortunately, they're also quite limited. Some pre-release versions of GameLisp experimented with a prototype-based object system, and encountered these problems:

  • Prototype chains tend to have poor performance, and the nature of the object model makes optimization difficult.
  • The hybrid object/tables tend to be weakly-typed and excessively dynamic. There's often no way to disable unhelpful operations like adding arbitrary fields to any object, or deleting any field, or iterating over every field. Abstractions end up feeling leaky and insecure.
  • The lack of dedicated syntax for things like methods and fields causes many quality-of-life papercuts. Methods being called as non-method functions accidentally; silent name collisions; no easy way to differentiate a general-purpose hash table from an object; no good way to make an individual table field immutable; etc.
  • Prototypes are a natural fit for single inheritance, but a poor fit for more complicated kinds of class relationship, like those which we'll explore in the next two chapters.

State Machines

The behaviour of most game entities can be at least partially described by a state machine. The door might be opening, open, closing, or closed. The gold ring might be bouncing around, flickering as it's about to vanish, or disappearing in a flash of light as it's collected. The ghost might be playing an animation as it springs out of its hiding-place, or it might be slowly gliding towards the player character. The main menu might be fading in, showing the title screen, showing the settings screen, or fading out.

As a Rust programmer, you probably understand better than most the value of state machines for modelling programs. Rust's enums are one of its greatest strengths: they take what would be a vague, implicit set of state transitions, and change them into something completely explicit and obvious which can be statically checked by the compiler.

As we saw in the Coroutines chapter, describing a state machine using traditional object-oriented code is very challenging. Consider a frog which cycles between crouching, jumping and croaking:

(defclass Frog
  (field color 'green)

  (field state 'crouching)

  (field state-change-timer 3.0)
  (field next-state 'croaking)

  (field elevation 0.0)
  (field vertical-speed 0.0)
  (const gravity -9.81)

  (meth on-step (elapsed)
    (match @state
      ('crouching
        (dec! @state-change-timer elapsed)
        (when (<= 0.0 @state-change-timer)
          (match @next-state
            ('crouching
              (bail))
            ('jumping
              (= @vertical-speed 3.0)
              (= @state 'jumping))
            ('croaking
              (= @state 'croaking)
              (= @state-change-timer 2.0)))))

      ('jumping
        (inc! @elevation (* elapsed @vertical-speed))
        (inc! @vertical-speed (* elapsed @gravity))
        (when (>= @elevation 0.0)
          (= @elevation 0.0)
          (= @state 'crouching)
          (= @state-change-timer 1.0)
          (= @next-state 'croaking)))

      ('croaking
        (inc! @state-change-timer elapsed)
        (when (<= 0.0 @state-change-timer)
          (= @state 'crouching)
          (= @state-change-timer 3.0)
          (= @next-state 'jumping))))))

This is code which only a mother could love. The object scatters its state variables across a mess of different toplevel fields, which stick around even after their state is complete, and which are sometimes overloaded between one state and the next. The intended lifetime of each field is unclear. Understanding the flow of control takes a fair amount of conscious effort. Adding in nested state machines, or multiple simultaneous state machines, would be a great challenge.

On the other hand, if we were to try to formalize this state machine in a language like Java or Rust, it could easily become over-engineered. You don't want to create an entirely separate type, or potentially even a hierarchy of types, just to manage control flow in a forty-line class.

When programming a game, you'll face this dilemma for just about every entity which you come across. It wouldn't be unusual for a game to contain hundreds, or even thousands, of state machines. When they're all defined using code which looks like the above, it's not a pretty sight.

This brings us to GameLisp's state clauses:

(defclass Frog
  (field color 'green)

  (fsm
    (state* Crouching
      (field timer)
      (field next-state)

      (init-state ((? @timer 3.0) (? @next-state 'Jumping)))

      (meth on-step (elapsed)
        (dec! @timer elapsed)
        (when (<= @timer 0.0)
          (@enab! @next-state))))

    (state Jumping
      (field elevation 0.0)
      (field vertical-speed 3.0)
      (const gravity -9.81)

      (meth on-step (elapsed)
        (inc! @elevation (* elapsed @vertical-speed))
        (inc! @vertical-speed (* elapsed @gravity))
        (when (<= @elevation 0.0)
          (@enab! 'Crouching 1.0 'Croaking))))

    (state Croaking
      (field timer 2.0)

      (meth on-step (elapsed)
        (dec! @timer elapsed)
        (when (<= @timer 0.0)
          (@enab! 'Crouching))))))

States

By default, a state introduces a binary state machine. It represents part of a class which can be switched on and switched off. state* clauses are enabled by default, whereas state clauses are disabled by default.

States may contain most of the same clauses which are permitted at the toplevel of a class: field, const, prop, meth, classmacros, and so on. The difference is that, while the state is disabled, its contents "stop existing". Its fields, constants and properties can't be accessed, and its methods can't be invoked.

A state can be enabled by calling the function (enab! obj name), where name is the state's name as a symbol, e.g. 'Opening. Similarly, disab! can be used to disable a state, and enab? to test whether a state is currently enabled.

Within a method, (@enab! name) is shorthand for (enab! @self name), and likewise for @disab! and @enab?. @state-name will return the name of the enclosing state.

(defclass Gem
  ...

  (state Sparkling
    (meth stop-sparkling
      (@disab! @state-name))

    ...))

(let gem (Gem))
(prn (enab? gem 'Sparkling)) ; prints #f

(enab! gem 'Sparkling)
(prn (enab? gem 'Sparkling)) ; prints #t

(.stop-sparkling gem)
(prn (enab? gem 'Sparkling)) ; prints #f

(.stop-sparkling gem) ; an error

Finite State Machines

A single state by itself is rarely useful. What you usually need is a group of states which are mutually exclusive, so that no more than one of the states can be enabled at any given moment. This can be achieved using an fsm clause.

(defclass Fighter
  (fsm
    (state* Neutral
      (const defense 50))
    (state Guarding
      (const defense 150))
    (state Staggered
      (const defense 0))))

When a state within an fsm clause is about to be enabled, but one of its siblings is already enabled, that sibling is automatically disabled first. In this case, if we were to call (enab! fighter 'Guarding), it would automatically call (disab! fighter 'Neutral) first.

Nested States

A state clause may appear within another state clause, establishing a hierarchy of state machines.

If you attempt to enable a child state, and its parent is disabled, the parent will automatically be enabled first.

Similarly, if you disable a parent state when any of its children are enabled, those child states will automatically be disabled first.

(defclass Owl
  (fsm
    (state* Sleeping
      ...
      (meth on-startled ()
        (@enab! 'Awake))) ; disables Sleeping, enables Fleeing

    (state Awake
      (fsm
        (state* Fleeing
          ...
          (meth on-collide (other)
            (when (is? other TreeBranch)
              (@enab! 'Perching other)))) ; disables Fleeing
        
        (state Perching
          ...
          (meth on-step ()
            (unless (humans-nearby? @self)
              (@enab! 'Sleeping)))))))) ; disables Perching and Awake

Initialization and Finalization

A state may include an init-state clause and/or a fini-state clause. These are analogous to the init and fini clauses which can appear at the toplevel of a class.

init-state defines a method which is automatically invoked just after the state is enabled. Its arguments are the same arguments which were passed to enab! or @enab!.

(defclass Cog
  (fsm
    (state* Immobile
      (meth on-activate ()
        (@enab! 'Mobile (rand-select -1 1))))

    (state Mobile
      (field direction)
      (field rotation-rate)

      (init-state (@direction)
        (= rotation-rate (* @direction 0.3))))))

When a state is enabled automatically by GameLisp (e.g. if it's a parent state whose child state is enabled, or if it was defined using state* rather than state), then its initializer is invoked with no arguments.

fini-state defines a cleanup method which is automatically called just before the state is disabled. It will also be called if the object is killed.

It's possible to call @enab! and @disab! from within an init-state or fini-state method, but it's not recommended. It can make the order of operations confusing, and in the worst case it might trigger an endless loop of state changes.

Errors

If an error occurs during initialization or finalization, the object will be left in an incoherent state. Child state* forms may not have been automatically enabled, a state passed to disab! may not actually have been disabled, an init method may have only been executed halfway, and so on.

This is almost never a recoverable situation, so GameLisp takes no chances: if an error bubbles through an init, fini, init-state or fini-state method, the object is immediately killed without any finalizers being run.

Shadowing

If you have a global or local variable bound to the name dragon, and you define a new local variable using (let dragon ...), then any references to the name dragon will refer to the new binding rather than the older bindings. We say that the later local binding "shadows" the earlier bindings.

The same is true for fields and constants in states. It's possible for a name to be bound by several different states at the same time, all of which might be simultaneously enabled. Under those circumstances, when GameLisp evaluates an expression like @dragon or [obj 'dragon], it needs to choose which binding takes priority.

The rules are:

  • Names in child states will shadow names defined by their parent.

  • Names in any state will shadow names defined by sibling states which appear textually earlier in the class definition.

These are essentially the same rules which govern local variable bindings.

(defclass ShoppingCentre
  (field tax-revenue 10_000)

  (state WellKnown
    (field tax-revenue 15_000))

  (state Damaged
    (field tax-revenue 2_000)

    (state Demolished
      (field tax-revenue 0))))

(let shops (ShoppingCentre))
(prn [shops 'tax-revenue]) ; prints 10000

(enab! shops 'Demolished)
(prn [shops 'tax-revenue]) ; prints 0

(enab! shops 'WellKnown)
(prn [shops 'tax-revenue]) ; prints 0

Fully-Qualified Names

If you need to access a field or constant in a specific state, you can use its fully-qualified name, StateName:field-name, to bypass the normal shadowing rules. For the purpose of name lookup, all fields and constants defined in the toplevel of a class are considered to belong to a Main state which can never be disabled.

(defclass FancyChair
  (const comfort 75)
  (const room 40)
  (const fun 5)

  (state Grubby
    (const comfort 40)
    (const room 10)

    (state Filthy
      (const room -30))))

(let chair (FancyChair))
(enab! chair 'Filthy)

(prn [chair 'comfort]) ; prints 40
(prn [chair 'room]) ; prints -30
(prn [chair 'fun]) ; prints 5

(prn [chair 'Main:comfort]) ; prints 75
(prn [chair 'Grubby:room]) ; prints 10
(prn [chair 'Filthy:room]) ; prints -30

This highlights a quirky detail of how state namespaces work: state names don't actually form a hierarchy. A state Child defined within the state Parent defined within the Main state is just called Child, rather than Main:Parent:Child. This means that you can't simultaneously have, say, Defending:KnockedBack and Attacking:KnockedBack - that would be a name collision, because both states are actually just named KnockedBack.

This is a deliberate design choice. Being able to define multiple different states which share the same name would be confusing, and typing out fully-qualified names would be too much effort. Flattening the namespace hierarchy is an effective solution.

Wrapper Methods

You will sometimes want to change the behaviour of a method depending on which states are enabled. For example, when the main character is being controlled by a cutscene script rather than being directly controlled by the player, you might want to override their on-input event handler to do nothing.

A naive attempt to achieve this using name shadowing will fail:

(defclass Character
  (meth on-input (input-event)
    (match [input-event 'tag]
      ('left (@walk-left))
      ('right (@walk-right))
      ('pause (game:pause))))
  
  (state CutsceneControl
    (meth on-input (input-event)
      ; do nothing
      #n)))

(let mc (Character))
(enab! mc 'CutsceneControl) ; error: name collision for 'on-input

It's not possible to have multiple active meth forms which share the same name. This is because, although name-shadowing is adequate for fields and constants, it's not powerful enough for methods. We provide a better alternative.

A (wrap ...) clause defines a "wrapper method": a method which replaces, and modifies, a method in another state.

(defclass Character
  (meth on-input (input-event)
    (match [input-event 'tag]
      ('left (@walk-left))
      ('right (@walk-right))
      ('pause (game:pause))))
  
  (state CutsceneControl
    (wrap Main:on-input (input-event)
      ; do nothing
      #n)))

In this case, when the CutsceneControl state is active, any calls to (.on-input ch ev) will be routed to the wrapper method in CutsceneControl. It would still be possible to invoke the original method using its fully-qualified name: (.Main:on-input ch ev) or (@Main:on-input ev).

Within our wrapper method, we can invoke the original meth on-input by calling (@base). This is a versatile tool. We could ignore the base method altogether, execute some additional code before or after calling (@base), transform the base method's arguments or return value, or even call the base method multiple times!

For example, some cutscenes might want to give the player a limited ability to move the main character around, but still forbid them from opening the pause menu. This would be easy to achieve using (@base):

(defclass Character
  (meth on-input (input-event)
    (match [input-event 'tag]
      ('left (@walk-left))
      ('right (@walk-right))
      ('pause (game:pause))))
  
  (state CutsceneControl
    (wrap Main:on-input (input-event)
      (unless (eq? [input-event 'tag] 'pause)
        (@base input-event))))

Chained Wrappers

You will have noticed that the wrap clause receives a fully-qualified name for its target method: in this case, Main:on-input.

The target is usually a meth form, but it's also possible to recursively wrap another wrap form. The wrapper methods form an orderly stack, with each (@base) call moving down the stack until it reaches the meth.

Let's suppose that we're writing an action game (or a business simulation game?) with a BerserkerBoss entity who turns progressively more red and angry as the encounter goes on:

(defclass BerserkerBoss
  (meth ruddiness ()
    (+ @attacks-received @henchmen-defeated))

  (state Angry
    (wrap Main:ruddiness ()
      (match @difficulty-level
        ('easy
          (* 1.2 (@base)))
        ('hard
          (* 1.4 (+ 3 (@base))))))

    (state Furious
      (wrap Angry:ruddiness ()
        (* 1.5 (@base))))))

When the Furious state is enabled, its parent state Angry must also be enabled. The original definition of ruddiness is wrapped by Angry:ruddiness, which is in turn wrapped by Furious:ruddiness, so a non-specific call to the ruddiness method will end up invoking Furious:ruddiness. Each wrapper delegates down the chain through successive calls to (@base).

Property Wrappers

You can wrap a property in much the same way that you might wrap a method. Simply define a (wrap-prop ...) clause. If we wanted to refactor ruddiness to be a property rather than a method, we would write:

(defclass BerserkerBoss
  (prop ruddiness (get (+ @attacks-received @henchmen-defeated)))

  (state Angry
    (wrap-prop Main:ruddiness
      (get
        (match @difficulty-level
          ('easy
            (* 1.2 (@base)))
          ('hard
            (* 1.4 (+ 3 (@base)))))))

    (state Furious
      (wrap-prop Angry:ruddiness (get (* 1.5 (@base)))))))

Property wrappers can't use the @field shorthand to access the original property's backing storage. Instead, they should invoke the original getter or setter using (@base).

Zombie Methods

A state may be disabled partway through executing one of its own methods. Similarly, an object may call (obj-kill! @self) from within one of its own methods. In both cases, this will land you in an awkward grey area. Code will be executing which appears to belong to a state or object, even though the state or object no longer exists. Under those circumstances, any @name field accesses or (@name) method calls will usually trigger an error. I call this situation a "zombie method".

(defclass Person
  (meth check-health ()
    (when (<= @health 0)
      (obj-kill! @self))

    ; execution continues after the object is killed. braaains...

    (when (== @health 100) ; error: nonexistent field 'health
      (prn "feeling pretty healthy!"))))

(For the record, this is a problem which already exists in many game state machines - it's just something which GameLisp makes explicit, rather than leaving it as a silent logic error.)

There's no practical way for GameLisp to prevent this. It would require dynamic checks to be inserted every time control flow leaves the body of a method, which would be difficult to implement and carry a huge performance cost.

Zombie methods tend to fail loudly rather than introducing subtle bugs, so they're mostly just an annoyance. You could consider using two techniques to make zombie methods less common:

  • Get into the habit of calling return immediately after (@disab! 'CurrentStateName). You might like to combine the two calls by defining a macro - perhaps (done), (end), or (switch-to state-name).

  • Postpone any (obj-kill!) calls until the very end of each frame. (As it so happens, most game engines already do this by default. Entity deletion is a common source of bugs, probably 99% of which can be prevented by postponing the deletion.)

Code Reuse

In game development, you'll often have a small piece of data and behaviour which you need to duplicate between many different classes. In a go-kart racing game, you might require all entities to register their bounding box with the collision system; or in a city simulator, you might have many different buildings which have their own population count and tax revenue; or in a 3D exploration game, you may wish to plug hundreds of diverse entities into the same animation system.

You already have several ways to achieve this sort of code reuse:

  • In many cases, the code can simply be refactored into a free function. "The on-grow method for all of my fruit tree classes is identical - they should be calling a shared grow-fruit-tree function instead."

  • Duck typing can be an elegant solution for some simple cases, but you normally can't use it to uphold complex invariants. "If I'm creating an entity, and I notice that has a target-rect field, I will automatically register it with the combat-targeting system."

  • Composition over inheritance will explicitly fracture your classes into small, reusable components which are separate from the whole. This will certainly make each component reusable, but it can be a hassle. "When I create a Skyscraper, it automatically creates a new PopulationNode and TaxNode."

  • If the only thing the entities have in common is that they all belong to the same general category, and some external system needs to manipulate them all in the same way, then you could implement a tag database. "Each frame, my Lava entity looks up any nearby entities with the heat-sensitive tag, and calls their on-heat method."

  • Consider whether the differences between your entities can be described using plain data rather than code. If you were to reimplement the original Final Fantasy in GameLisp, you wouldn't need a distinct class for an Ogre and a Creep - you would just have a single Enemy class which makes use of plain data like "hit points" and "sprite size". Likewise, if you were to reimplement The Sims, all of the different doorways would probably be modelled as a single Door class which is capable of displaying a few different sprites.

Occasionally, none of those options will be sufficient. This usually happens when a component is shared by a very large number of entities (dozens or hundreds), and those entities need to frequently interact with the component in some intrusive way, so splitting it into a separate object would make things too bureaucratic. For example...

  • In an action-adventure game, we don't want to type out [@collider 'coords] every single time an entity needs to know its own coordinates; we just want to type @coords instead. On the other hand, an entity's physical location is a very fragile thing (if you get it wrong, entities will start clipping through the scenery!), so our physics system shouldn't just manipulate a bare @coords field using duck typing.

  • In a theme-park simulation game, the code for saving and loading the game needs to be able to serialize and deserialize most of the game's entities. Explicitly writing serialize and deserialize methods for every building, every visitor and every worker would be labour-intensive and tedious.

  • In a massively-multiplayer online game, the code for scripting the user-interface might need to filter or intercept certain methods. For example, if a user-interface element has scrollbars, then the arguments to its on-mouse-click method should be adjusted to give the illusion that it belongs to a different coordinate system. If each new type of user-interface element was forced to convert between coordinate systems manually, you would end up writing a lot of extra code, and bugs would certainly creep in.

In situations like these, your first port of call should be a macro or a classmacro. It would be straightforward to write a defentity macro which acts like defclass, but emits a few extra clauses to hook the resulting class into the savegame system and the collision system.

If you find that even macros aren't powerful enough, GameLisp does have one more trick up its sleeve.

Mixins

A mixin is a small class which can't be instantiated. Instead, it can only be "mixed into" the definition of another class. The target class will incorporate all of the mixin's fields, methods, states, and so on, almost as though they were simply copied and pasted at the beginning of the class definition.

(defmixin Sized
  (field width)
  (field height))

(defclass Box
  (mixin Sized)
  (init (@width @height)))

(let box (Box 20 15))
(prn [box 'width] [box 'height]) ; prints 20 15
(prn (is? box Box) (is? box Sized)) ; prints #t #t

This is similar to a classmacro, but it comes with several advantages:

  • Mixins can customize their object's initialization and finalization. (This would be difficult to achieve using a classmacro, since each class may only have a single init clause and a single fini clause, which can't normally be wrapped.)

  • Mixins introduce a new namespace. If your SoftBody mixin specifically wants to override a method introduced by your Collider mixin, it can include the clause (wrap Collider:something ...).

  • As demonstrated above, the (is? obj class) function can be used to test whether an object's class implements a particular mixin.

; a mixin which adds a `coords` property and a `move` method to a class, 
; and ensures that the collision system is kept up-to-date whenever the 
; coords are changed.
(defmixin Coords
  (prop coords 
    (get)
    (set (new-coords)
      (= @field new-coords)
      (colliders:update @self)))

  (meth move (dx dy)
    (colliders:move @self dx dy))

  (init-mixin (..args)
    (@base ..args)
    (colliders:register @self))

  (fini-mixin ()
    (colliders:unregister @self)))

Advanced Wrapper Methods

In the previous chapter, we discussed wrapper methods, which can be used to override a specific meth or wrap clause defined elsewhere in the class.

So far, the obvious limitation of wrapper methods is that they require you to know the entire structure of your class up front. There's a Jumping state, which wraps the Active:energy-level property, which wraps the Main:energy-level property...

Let's suppose you have an on-step method, and you want to write a mixin which spawns a particle effect every step, without replacing or modifying the entity's normal behaviour. GameLisp gives you two options for achieving that.

The first option:

(defmixin Cloudy
  (wrap Main:on-step ()
    (@base)
    (spawn-particle @coords 'clouds)))

If Main:on-step is undefined, or if you include multiple states or mixins which all try to override Main:on-step, or if a state other than Main tries to define a meth on-step, an error will occur. This is normally a good thing! It highlights the fact that your code has an ambiguous order of execution, and it prompts you to disambiguate it by, for example, changing one of your wrapper methods to override Cloudy:on-step instead.

In cases where you're absolutely sure that you don't care about the order of execution, you could consider the second option:

(defmixin Cloudy
  (wrap _:on-step ()
    (@base)
    (spawn-particle @coords 'clouds)))

The underscore makes this a "wildcard wrapper method". It means "I want this code to be executed when on-step is called, but I don't care about what happens before or after".

Wildcard wrappers are much less strict than explicit wrappers. It's fine to have a wildcard wrapper for _:on-step, even when there is no actual meth on-step anywhere in the class. If there is no other on-step method, or if it's disabled, (@base) will be a silent no-op. There can be any number of _:on-step wrappers in each class; you can even put several in the same state!

Wildcard wrappers can only be invoked using their unqualified method name: (.Cloudy:on-step ob) would fail, but (.on-step ob) would succeed. When you call an unqualified method like on-step, (@base) will chain through all of the wildcard wrappers in an unspecified order, followed by all of the explicit wrappers, followed by the meth form. Methods which belong to disabled states are skipped.

Although wildcard wrappers can lead to spaghetti code when overused, they're a powerful tool when used responsibly.

(class Monster
  (meth on-inspect ()
    (prn "It's terrifying!"))
  
  (state OnFire
    (wrap _:on-inspect ()
	  (@base)
	  (prn "Also, it's on fire!")))
  
  (state Howling
    (wrap _:on-inspect ()
	  (@base)
	  (prn "It's howling, too!"))))

Initialization and Finalization

Mixins are initialized using an init-mixin clause, which defines a wrapper for the class's initializer method. If a class has three mixins and an init clause...

(defclass
  (mixin A B C)
  (init
    ...))

...then it effectively has a hidden initializer method Main:init, which is wrapped by C:init-mixin, which is wrapped by B:init-mixin, which is wrapped by A:init-mixin.

Like any other wrapper method, init-mixin is versatile. It can intercept leading or trailing initializer arguments, modify arguments, and execute arbitrary code before or after calling (@base). The only thing it's not capable of doing is inverting the flow of information - a class can't decide which arguments to pass to each of its mixins - but this is a deliberate design choice.

Finalization is simpler than initialization. When an object is killed, GameLisp will first call the object's fini method, and then call fini-mixin for each mixin from right to left. fini-mixin isn't a wrapper method, so it doesn't need to call (@base).

States in Mixins

Mixins may define states. However, it's an error for a mixin to define a state which is also defined by the target class, or by another mixin. GameLisp provides no way to mix two states together, simply because it would be too confusing.

For similar reasons, mixins don't shadow their implementing class. If the toplevel of a mixin defines a field or constant which is also defined by the Main state of its implementing class, it's an error.

If a mixin defines a state, that state will participate in name-shadowing as normal, as though it was copied-and-pasted in at the very start of the implementing class.

(defmixin Heavy
  (const kg 1000)
  (state* Burdened
    (const kg 1100)))

; this is an error, because the name Weight:kg collides with Heavy:kg.
; if Heavy:kg were to be commented out, the code would compile.
(defclass Weight
  (mixin Heavy)
  (const kg 500))

Mixin States

It's ordinarily an error for a mixin and a state to share the same name, because it would cause a namespace collision:

; which one of these two fields is named `Fighting:health`?
(mixin Fighting
  (field health)
  (state Fighting
    (field health)))

However, there's a special exception when a mixin only contains a single state or state*, with no other clauses. In that case, the state "takes over" the mixin's namespace. In effect, you end up with a mixin which can be dynamically enabled and disabled - a useful abstraction.

Aside: Why Not Inheritance?

Most object-oriented languages in common use include an inheritance hierarchy. Code reuse is achieved by designating one or more "parent classes" for each class. All of the fields and methods in the parent class are incorporated into the child class.

I find that this is often an unhelpful abstraction, for two main reasons:

  • The boundary between a parent class and its children can be fiendishly difficult to manage. Designing ClassA so that it extends and improves ClassB sounds deceptively straightforward, but in reality it's anything but.

  • The problems with multiple inheritance are well-documented, but single inheritance is too limited. It tends to create awkward, towering inheritance hierarchies, bringing in many features which the final object doesn't actually need.

Mixins vaguely resemble an inheritance hierarchy, but they're deliberately simpler. Mixins can't include or require other mixins, so they form a flat list rather than a tree. Mixins don't have a general ability to override or shadow everything in the implementing class - they just have a limited ability to override initialization, finalization and methods. By convention, mixins are also much smaller than base classes: a mixin should define a small, reusable piece of code, rather than defining the entire foundation upon which another class will be built.

Emulating Inheritance

One thing which single inheritance excels at is defining multiple classes which are almost identical, but with only small differences in their logic. In a military-strategy game, if you have a red soldier with an aggressive AI and a musket, and a blue soldier with a defensive AI and a pike, then it would be natural to model them as:

class Soldier extends Entity { ... }
class RedSoldier extends Soldier { ... }
class BlueSolider extends Soldier { ... }

If you're trying to achieve something like this in GameLisp, I would strongly advise against defining a Soldier mixin. Mixins aren't designed to be used for inheritance; you'll be able to make it work with a little effort, but it won't be elegant.

Instead, you should use runtime configuration: a single Soldier class which accepts a color parameter. States make it easy to compartmentalize the class into two sub-types.

(defclass Soldier
  (init (color)
    (match color
      ('red (@enab! 'Red))
      ('blue (@enab! 'Blue))
      (_ (bail))))

  (state Red
    (const weapon-name 'musket)
    (meth select-action ()
      ...))

  (state Blue
    (const weapon-name 'pike)
    (meth select-action ()
      ...)))

Structs

Objects are fast and space-efficient, especially when they don't contain any state forms. If you have a small compound data structure (say, a geometric primitive, or the return value from a function), it's usually best to implement it as a class, rather than a table or an array.

(defclass Rect
  (field x)
  (field y)
  (field w)
  (field h)

  (meth init (@x @y @w @h))

  (meth area ()
    (* @w @h))

  (meth op-clone ()
    (Rect @x @y @w @h))

  (meth op-eq? (other)
    (let [x y w h] other)
    (and (== @x x) (== @y y) (== @w w) (== @h h))))

That's a lot of code for something so simple! We provide the defstruct macro to get rid of the boilerplate:

(defstruct Rect 
  x y w h

  (meth area ()
    (* @w @h)))

(prn Rect) ; prints #<class:Rect>
(let rect (Rect:new 10 10 20 20))
(prn rect) ; prints #<obj:Rect>
(prn [rect 'x]) ; prints 10
(prn (.area rect)) ; prints 400

As demonstrated above, the defstruct macro defines a class with the given named fields. After the list of field names, defstruct also accepts zero or more meth, prop and const clauses. Other class clauses, like init, state, fsm, wrap and mixin, are forbidden. Classmacros are also forbidden.

Struct Initialization

When programming in Rust, you will have encountered tuple structs: structs which identify their fields by position, rather than by name.


#![allow(unused_variables)]
fn main() {
struct Raster {
	pixels: Vec<u32>,
	width: u32,
	height: u32,
	format: ImageFormat
}

// vs.

struct Raster(Vec<u32>, u32, u32, ImageFormat);
}

Tuple structs with more than one field are generally discouraged. They're hard to understand, hard to refactor, and they require more documentation. This is doubly true in a dynamically-typed language. For example, if you wanted to change the order of a tuple's fields in a Python codebase, the language would give you no help at all; it would be a completely manual task.

To encourage the use of named (rather than positional) struct fields, defstruct registers a global initialization macro which shares the struct's name. This macro behaves like a Rust struct initializer, or like the tab macro:

(defstruct Hit
  hitbox
  strength
  element)

(let hitbox (Rect @x @y w h))

(let fire-punch (Hit
  hitbox
  (strength 35)
  (element 'fire)))

(let ice-punch (Hit
  (element 'ice)
  ..fire-punch))

; the Hit macro resembles a table constructor
(let fire-punch-table (tab
  ('hitbox hitbox)
  ('strength 35)
  ('element 'fire)))

The original class is bound to the global Name:new. This enables you to easily opt in to using positional arguments, when you think they would be the better choice.

(defstruct Rgb r g b)

(let named (Rgb (r 32) (g 139) (b 32)))
(let positional (Rgb:new 32 139 32))

(prn (eq? named positional)) ; prints #t

Finally, the global Name? is bound to a function which tests whether or not a value is a Name struct. This is more convenient and intuitive than writing (is? val Name) every time.

(let chartreuse (Rgb 0x7f 0xff 0x00))
(prn (Rgb? chartreuse)) ; prints #t

Operator Overloading

The default behaviour of the eq? function, when comparing two objects, is to test them for identity using the same? function. This means that two objects can belong to the same type, and store the same values, but still compare unequal to one another.

You can override this default behaviour by defining a method named op-eq?.

As noted above, op-eq? is automatically implemented by the defstruct macro. It will compare each struct field in turn using eq?.

(defclass Spawner
  (field level) ; a large, immutable table
  (field to-spawn) ; a class
  (field remaining) ; an integer counter

  (meth op-eq? (other)
    (and
      (same? @level [other 'level])
      (same? @to-spawn [other 'to-spawn])
      (== @remaining [other 'remaining]))))

By default, the clone and deep-clone functions only duplicate a reference to an object; they don't copy the object's storage. You can provide op-clone and op-deep-clone methods to override this behaviour.

Errors

GameLisp's error-handling story is quite minimalist. Errors silently bubble up through the call-stack; they can be caught by the Rust API or caught within GameLisp code; and uncaught errors print a stack trace and an error message. Internally, they're implemented using Result.

(defmacro add-5 (form)
  `(+ 5 ~form))

(defn recursive (n)
  (cond
    ((> n 0)
      (recursive (- n 1)))
    (else
      (add-5 'symbol))))

(recursive 2)

#|
    stack trace:
        glsp::load("example.glsp")
        (recursive) at example.glsp:11
        (recursive) at example.glsp:7
        (recursive) at example.glsp:7
        (add-5) at example.glsp:9
            expanded to (+) at example.glsp:2

    error: non-number passed to a numeric op
|#

You can manually trigger an error by calling bail or ensure, which resemble Rust's panic!() and assert!() respectively. They accept any number of arguments to describe their error message. When two or more error-message arguments are present, those arguments are converted to a string, as though they had been passed to the str function.

(bail "expected {expected-type} but received {(type-of arg)}")

(ensure (>= [stats 'level] 10) "level is too low. stats: " (pretty-stats))

ensure doesn't actually evaluate its error-message arguments unless an error occurs, so it's safe to use expensive function calls when describing the error.

Error Recovery

In game development, closing down the release-build executable when an error occurs should be the last resort - something only done for unrecoverable errors. For some errors, it's safe to simply log the fact that an error occurred, and then continue executing.

Consider code which loops through each of your game's entities and calls a function to render them to the screen. If you catch any error produced by an entity's rendering function, and then move on to the next entity as normal, it could potentially change a catastrophic bug (the game crashing) into a minor one ("due to a calculation error, the fire elemental's particle effect sometimes disappears").

You can catch errors within GameLisp code using the macros try and try-verbose:

(for entity in draw-list
  (match (try (draw-entity entity))
    (('ok result)
      #n)
    (('err payload)
      (log-error entity payload))))

try evaluates its child forms within an implicit do block. When no error occurs, it returns the two-element array (ok result), where the first element is the symbol ok, and the second element is the result of evaluating its last child form.

If an error occurs within the try form's dynamic scope - including errors generated internally by GameLisp, a Result::Err being returned by a Rust function, or even a panic!() within a Rust function - the try macro will catch the error and return (err payload), where the first element is the symbol err and the second element is a value which represents the error.

When bail or ensure are called with a single argument, that argument will not be converted into a string. This means that payload can have any type. You could potentially use it to set up a more structured and formal error-handling scheme.

(let ('err payload) (try (bail 100)))
(prn (int? payload)) ; prints #t

(bail (tab
  ('error-kind 'missing-resource-error)
  ('filename "space-station.lvl")
  ('resource-name 'laser-rifle)
  ('recoverable #t)))

try-verbose is identical to try, but when an error occurs it returns (err payload stack-trace), where stack-trace is a string describing the call-stack when the error was generated. Stack-trace generation can cost a lot of time, sometimes in excess of one millisecond, so try-verbose should be used sparingly.

Exception Safety

There is a downside to capturing errors. If a function makes a series of changes which are globally visible (like mutating an object field, mutating a global variable, or enabling or disabling a state), an error could cause the function to suddenly stop executing, even if those changes are only partially complete. Capturing that error would leave your game in a buggy, incoherent state.

It's technically possible to guarantee coherency by using language features like defer, but maintaining exception safety across your whole codebase would be a huge engineering challenge. For the average game codebase, it's certainly not worth the effort.

Instead, it's usually best to only capture errors when they originate from "read-only" code which doesn't mutate any global data. This might include your rendering code, or the function which deserializes a level file, or the function which writes a saved game to the file system. When you do capture an error, you should capture it as far down the call stack as possible, to limit the number of function calls which it might disrupt.

Debugging

GameLisp's debugging facilities are not yet very mature.

The dbg macro works like Rust's dbg!(): each argument's line number, form and return value are printed to the standard error stream.

(let variable 10)
(dbg (+ 2 3) variable)

#|
    prints:

    [example.glsp:2] (+ 2 3) = 5
    [example.glsp:2] variable = 10
|#

Likewise, the todo macro is designed to resemble Rust's todo!(). It calls (bail) with an error message along the lines of "not yet implemented".

The file-location function returns a brief filename and line number as a string, like "scripts/somewhere.glsp:42".

The stack-trace function returns a full stack-trace string, as described above.

Cleanup

You'll be familiar with the use of Drop in Rust to clean up resources like file handles and mutex locks.

This type of scoped resource-handling is less common in GameLisp, but you might still need to execute cleanup code from time to time. This can be achieved using the defer special form. defer executes a number of forms when control exits from its enclosing lexical scope, whether that's because of normal execution, return, continue, break, restart-block, finish-block, or an uncaught error.

; prints: first second third fourth
(defer (prn "fourth"))
(do
  (defer (pr "third "))
  (do
    (defer (pr "second "))
    (pr "first "))
  (bail)
  (prn "this line is unreachable"))

yield is more complicated. With yield, it's possible to leave a lexical scope, and then return to it later on using coro-run. Many other languages simply don't perform cleanup when yielding out of a coroutine, which can lead to unexpected resource leaks. To avoid this, we provide the defer-yield special form, which executes one form every time a yield exits its lexical scope, and another form every time coro-run resumes into its lexical scope.

The combination of defer and defer-yield is powerful. They allow you to guarantee that a particular condition holds for the entire duration of a dynamic scope (that is, the forms which fall within a particular lexical scope, and any functions which those forms call, and any functions called by those functions...). We use this to provide the with-global macro, which overrides the value of a global variable for the duration of a dynamic scope:

(defmacro with-global (name value-form)
  `(splice
    (let old-value# (global '~name))
    (let new-value# ~value-form)
    (= (global '~name) new-value#)
    (defer
      (= (global '~name) old-value#))
    (defer-yield
      (do
        (= new-value# (global '~name))
        (= (global '~name) old-value#))
      (do
        (= old-value# (global '~name))
        (= (global '~name) new-value#)))))

(defn hello-world ()
  (prn "hello, world"))

; make all prn calls much more exciting within a dynamic scope
(let boring-prn prn)
(with-global prn (fn (..args) (boring-prn ..args "!!!")))

(hello-world) ; prints hello, world!!!

with-global is as powerful as Racket's parameters and Common Lisp's dynamic variables, but we were able to implement it as a simple macro, without any special support from the language.

Syntax Information

When an array is parsed from a file, it's tagged with a small amount of hidden syntax information which indicates the file and line number from which it was parsed. This syntax information is essential for reporting a meaningful backtrace when an error occurs.

You normally won't need to think about this. Macros, expand and backquote automatically generate appropriate syntax information for you.

The exception is when you manually transform one array into another within a macro - for example, by calling (arr ..(rev source-arr)). The arr function has no way of knowing that its result should be syntactically similar to source-arr, so syntax information is not preserved.

One solution is to clone the source array, and then mutate the resulting array in-place, perhaps by clear!ing it and then rebuilding it from scratch. clone preserves syntax information, so the new array will have the same syntax information as the source array.

Alternatively, the map-syntax function takes an array and a mapping function, and returns a new array with the same syntax information, where each element was created by calling (mapper source-element).

Miscellaneous

Freezing Data

GameLisp's variables and collections are mutable by default. This is convenient, but it can also be nerve-wracking. It's sometimes difficult to know whether or not you have exclusive ownership of a collection, so there's always the risk that typing (push! ar x) or (clear! coll) could have unintended side-effects in some distant part of your codebase.

The freeze! function takes any number of GameLisp values as arguments. For each value which refers to an array, string, table, or object, a flag is set on that collection which prevents it from being mutated. Future calls to (= [ob 'x] 10), (swap-remove! ar 5) and so on will trigger an error. Frozen collections can never be unfrozen, and they can't even be mutated using the Rust API.

freeze! is not recursive: you can always mutate a non-frozen collection, even if you're accessing it via a frozen collection. To freeze a value and all of the values which it transitively refers to, use deep-freeze! instead.

Finally, you'll sometimes want the security of knowing that a global variable can't be accidentally mutated. You can enforce this by calling freeze-global!. (GameLisp automatically freezes a few of the built-in functions, such as +, so that they can be optimized more aggressively.)

(def ar (arr (arr 'a 0) (arr 'b 1)))

(push! ar (arr 'c 2))
(prn ar) ; prints ((a 0) (b 1) (c 2))

(freeze-global! 'ar)
;(= ar '()) ; this would be an error

(freeze! ar)
;(push! ar (arr 'd 3)) ; this would be an error
(push! [ar 0] 0)
(prn ar) ; prints ((a 0 0) (b 1) (c 2))

(deep-freeze! ar)
;(push! [ar 1] 1) ; this would be an error

Literals

Using quote or self-evaluating types, it's possible to create multiple aliasing references to data which was originally passed in to the evaluator. For example:

(loop
  (let ar ())
  (push! ar 1)
  (prn ar))

Intuitively, you might expect ar to be a fresh, empty array each time it's initialized, so that the prn call prints (1) every time. Unfortunately, because empty arrays are self-evaluating, the () form will repeatedly return the same array. This loop would print (1), then (1 1), then (1 1 1)...

In order to prevent this, whenever GameLisp encounters a quoted or self-evaluating array, string or table, if it's not already deep-frozen then it will be replaced with a deep-cloned, deep-frozen copy of itself. This means that self-evaluating forms and quoted forms are always immutable, avoiding problems like the above.

To make unintended mutation even less likely, all data produced by the parser is automatically frozen. You can clone the data if you need a mutable copy.

Cloning Data

The clone function receives a single value as its argument. If this value is an array, string, table, or iterator, it returns a shallow copy of that value. The copy will be mutable, even if the original collection was frozen.

The deep-clone function shallow-clones its argument, and then recurses through to clone any collections referred to by the argument, and any collections referred to by those collections, and so on.

Cloning an iterator will never clone the array, string or table which is being iterated, even when using deep-clone. When they encounter an iterator, both clone and deep-clone perform just enough copying to ensure that iter-next! will not modify the original iterator.

Equality

GameLisp provides four different equality tests. This is a necessary complication: in languages like Rust, the == operator is heavily overloaded, which it its own source of complexity.

In GameLisp, the == function specifically tests for numeric equality. It exists to complement the other numeric comparison functions: <, <=, >= and >. It's an error to pass a non-numeric value to ==.

The same? function tests for identity. For all reference types, two references are the same? if they point to the same allocation.

same? is usually more strict than you'd like. For example, (same? '(1 2) (arr 1 2)) will return #f, because it sees its arguments as being two distinct arrays, even though they have identical contents.

You'll generally want to use the eq? function instead. It differs from same? in that arrays, strings and tables are deeply inspected: they compare equal when their contents are recursively identical.

The final built-in equality test is keys-eqv?, which was discussed previously.

Multiple Comparisons

Functions like == and eq? are variadic. (== a b c d) tests whether a, b, c and d are all == to one another.

If you need to test one value against multiple others, you can use the functions ==any?, same-any? and eq-any?.

(ensure (eq-any? (coro-state c) 'newborn 'running 'paused 'finished 'poisoned))

(when (same-any? current-room graveyard dungeon prison-cell)
  (inc! fear-level))

Time and Date

Because the Rust standard library only has limited facilities for handling time, the same is true for GameLisp.

The time function returns a high-precision monotonic timestamp measured in seconds, and the sleep function suspends the current thread for a specified number of seconds. Note that although time should have excellent precision on all platforms, sleep is often very imprecise, particularly on Windows. You should prefer to time your main loop using an external signal, such as blocking on VSync.

The only date-and-time facility provided by GameLisp is the unix-time function, which returns the number of elapsed whole seconds in the UNIX epoch. In order to avoid the 2038 problem it returns a string, such as "1153689688". It's intended to be used as a basic timestamp for logging - it can't be readily converted into a human-readable format.

Last But (not least)...

The not function returns #t for a falsy argument, and #f for a truthy argument.

The identity function accepts a single argument and returns it unchanged. The no-op function accepts any number of arguments and returns #n. Both are occasionally useful as first-class functions.

The fn0 and fn1 macros provide a concise way to define functions with zero or one arguments. fn1 can use a single underscore, _, to refer to its argument. Within a fn1 form, you should try to avoid discarding a value in a pattern (e.g. (match ... (_ x))), since it can be visually confusing.

(ensure (all? (fn1 (eq? _ 'red)) colors) "non-red color detected")

; ...is equivalent to...

(ensure (all? (fn (color) (eq? color 'red)) colors) "non-red color detected")

In Section 2, we'll discuss how to invoke and tune the garbage collector from Rust. You might find that you prefer to do that from within GameLisp, in which case you can use the functions gc and gc-value.

Input/Output

You may have noted the conspicuous absence of an input/output (IO) library. GameLisp doesn't provide any built-in functions for filesystem access, beyond load, require and include.

This is a deliberate design choice. IO is a huge topic: a full-featured IO library for game development would need to include byte streams, compression, string streams, stdio, text encodings, networking, non-blocking io, sandboxing, logging... the list goes on.

Meanwhile, your game engine will almost certainly have its own opinions about how filesystem and network access should be done. Something as simple as storing your game's assets in a .zip file might make the entire IO library unusable!

Instead, you're encouraged to use the Rust API to bind your engine's existing APIs to GameLisp.

The Rust API

This section will take you on a guided tour of the glsp crate: the Rust API which can be used to create and manipulate a GameLisp runtime.

Binding script code to native code tends to be a pain point when using languages like Lua and Python. GameLisp leverages Rust's powerful type system and macro system to make its interface with Rust as seamless as possible.

The glsp Crate

Let's take another look at the skeleton project from the Overview chapter:

use glsp::prelude::*;

fn main() {
	let runtime = Runtime::new();
	runtime.run(|| {
		glsp::load("main.glsp")?;
		Ok(())
	});
}

The main player here is the Runtime type. It owns all of the data required to run a single GameLisp instance: a garbage‑collected heap, a symbol registry and a call stack, among other things. When the Runtime is dropped, all of that data is automatically freed.

There can be many simultaneous Runtimes in a program, and a Runtime can be created on any thread, but Runtimes (and handles to the data inside them) can never be moved from one thread to another. Although GameLisp can be gracefully integrated into a multithreaded program, each individual GameLisp runtime is entirely single-threaded.

The Active Runtime

Unusually for a Rust library, the glsp crate uses thread_local! variables internally. This means that there's no need to pass around a context object (aka "God object"), which would make the crate much less convenient to use.

Each thread has a private thread_local! variable which refers to its active Runtime. The rt.run(...) method sets rt to be the active Runtime, executes an arbitrary closure, and then restores the Runtime which was previously active, if any.

The closure's captures, arguments and return value must all implement the GSend auto trait. Because most of the types in the glsp crate impl !GSend, it's very difficult for any data from a Runtime to be smuggled into a scope where that Runtime is no longer active. (Rest assured that if this does happen, the library has dynamic checks which still guarantee memory‑safety.)


#![allow(unused_variables)]
fn main() {
//error: Sym does not implement GSend
let sym: Sym = runtime.run(|| {
	let sym = glsp::sym("hello")?;
	Ok(sym)
}).unwrap();

//no problem: LenWrapper automatically implements GSend
struct LenWrapper(usize);
let len: LenWrapper = runtime.run(|| {
	let sym = glsp::sym("hello")?;
	let len = sym.name().len();
	Ok(LenWrapper(len))
}).unwrap();
}

The glsp crate contains a large number of free functions, like glsp::sym, which manipulate the active Runtime in some way. If called when there is no Runtime active, they panic.

Most of those functions closely imitate an equivalent function which is built in to GameLisp. For example, glsp::sym is equivalent to the sym function, and glsp::load is equivalent to load.

Types

To introduce a few of the crate's most important types:

  • Val is the enum which represents a GameLisp value. Each variant corresponds to one of the sixteen primitive types.

  • Sym represents a GameLisp symbol. It's a small Copy type which just wraps a u32 identifier.

  • Root is a smart pointer which refers to something stored on the garbage-collected heap. It points to a struct which represents one of GameLisp's primitive types, such as Root<Arr> for an array or Root<Coro> for a coroutine.

  • GFn is the name for GameLisp's fn primitive type, to avoid confusion with Rust's Fn trait. Similarly, the GameLisp type iter is represented by the Rust type GIter.

Generic Conversions

Functions and methods in the glsp crate tend to be highly generic, to keep manual type conversions to a minimum.

For example, this is the signature of the glsp::global function, which is designed to imitate GameLisp's (global) function:


#![allow(unused_variables)]
fn main() {
pub fn global<S, T>(s: S) -> GResult<T> where
    S: ToSym,
    T: FromVal, 
}

The ToSym trait converts something to a symbol, and the FromVal trait is implemented by anything which can be fallibly converted from a Val. In practice, this means that the function can be called like this...


#![allow(unused_variables)]
fn main() {
let frames: u32 = glsp::global("frames")?;
}

...rather than needing a mess of explicit type conversions:


#![allow(unused_variables)]
fn main() {
let frames = u32::from_val(&glsp::global(glsp::sym("frames")?)?)?;
}

The only downside is that with so many generic types, Rust's type inference will sometimes get confused. Rust doesn't yet allow you to put type annotations wherever you please, so under those circumstances, you'll usually need to introduce a temporary local variable with an explicit type.

In particular, for functions like glsp::call which have a generic return value, the type of the return value must be specified explicitly, even when it's discarded.


#![allow(unused_variables)]
fn main() {
//an error
glsp::call(&my_gfn, &(1, 2, 3))?;

//correct
let _: Val = glsp::call(&my_gfn, &(1, 2, 3))?;
}

Comprehensive Coverage

The glsp crate aims to be comprehensive: if it's possible to achieve something in GameLisp code, it should also be possible to achieve it in Rust. It's usually possible for any GameLisp code to be translated into (much uglier) Rust code line-by-line.

(for plant in plants
  (when (< [plant 'height] 100)
    (.grow plant 10)))

#![allow(unused_variables)]
fn main() {
let plants: Root<Arr> = glsp::global("plants")?;
for val in plants.iter() {
	let plant = Root::<Obj>::from_val(&val)?;
    let height: i32 = plant.get("height")?;
    if height < 100 {
        let _: Val = plant.call("grow", &(10,))?;
    }
}
}

The Prelude

Importing the glsp::prelude::* module will pull in all of the most commonly-used names from the glsp crate, except for the free functions: glsp::bind_global() doesn't become bind_global().

I can't overstate how much more convenient this is, compared to manually adding and removing dozens of imports to every file. If name collisions occur, they can be disambiguated via renaming:


#![allow(unused_variables)]
fn main() {
use glsp::prelude::*;
use num::Num as NumTrait;
use error_chain::bail as ec_bail;
}

If glob imports aren't to your taste, naturally there's nothing stopping you from importing names individually instead.

Sandboxing

Runtime::new() creates a default Runtime. It's also possible to use the RuntimeBuilder struct to configure a Runtime before creating it.

Currently, the only configuration setting is sandboxed, which defaults to false. A sandboxed Runtime does not provide any of the built-in GameLisp functions which access the filesystem - namely load, include and require. Untrusted GameLisp code can get up to all sorts of mischief even without filesystem access, so you should still proceed with great caution when running it.

Output Streams

By default, prn will print its output to std::io::Stdout, and eprn will print its output to std::io::Stderr.

It's possible to replace those streams with an arbitrary std::io::Write type using the functions glsp::set_pr_writer and glsp::set_epr_writer. For example, to discard both streams:


#![allow(unused_variables)]
fn main() {
use std::io::sink;

glsp::set_pr_writer(Box::new(sink()));
glsp::set_epr_writer(Box::new(sink()));
}

Or to send output to a log file and also to the standard output streams:


#![allow(unused_variables)]
fn main() {
use std::io::{self, stdout, stderr, Write};

struct Tee<A: Write, B: Write>(A, B);

impl<A: Write, B: Write> Write for Tee<A, B> {
	fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
		self.0.write_all(buf).and_then(|_| self.1.write_all(buf))?;
		Ok(buf.len())
	}

	fn flush(&mut self) -> io::Result<()> {
		self.0.flush().and_then(|_| self.1.flush())
	}
}

//defining a cloneable file handle is left as an exercise for the reader
let log_file = SharedFile::new("log.txt");
glsp::set_pr_writer(Box::new(Tee(log_file.clone(), stdout())));
glsp::set_epr_writer(Box::new(Tee(log_file.clone(), stderr())));
}

Unless you manually override them, the Rust macros print!(), println!(), eprint!() and eprintln!() will still print to the standard output and standard error streams. As an alternative, you can use the macros pr!(), prn!(), epr!() and eprn!() to print to the active Runtime's pr_writer and epr_writer.

Collection Types

The collection types are Arr for arrays, Str for strings, and Tab for tables. Their API has a few quirks to bear in mind.

Error-Handling

We try to keep panics to an absolute minimum. Because there are a lot of things which can potentially go wrong when interacting with a collection (out-of-bounds errors, failed type conversions, mutating a borrowed collection), almost every collection method returns a GResult. You'll need to make generous use of the ? operator.

Interior Mutability

All of the collection types are both aliasable and mutable - in Rust, this requires interior mutability. There are two ways this could potentially have worked:

  • Allow the Rust programmer to explicitly lock the interior of the type, as though working with a RefCell, and then mutate it freely - perhaps even directly accessing the internal storage.

  • Build the API around "atomic" method calls which lock the type, do something to it, and then unlock it before returning.

GameLisp previously chose the first solution, but it ended up cluttering the Rust code with too many calls to borrow() and borrow_mut(), so we switched to the second option. The second option also causes fewer lifetime problems in practice, since borrow() handles tend to be scoped for a longer lifetime than necessary.

The only methods which aren't completely atomic are those which create a Rust iterator over a collection. If a collection is mutated while being iterated in Rust, an error will occur.

Because we don't allow the caller to create direct references into a collection's storage, none of our collections can be indexed using Rust's [] syntax. You'll need to use the get() and set() methods instead.

Construction

There is no way to place an Arr, Str or Tab directly on the Rust stack. Instead, they're always accessed indirectly via a Root smart pointer: Root<Arr>, Root<Str>, Root<Tab>.

New collections can be allocated using global functions in the glsp namespace, such as glsp::arr, glsp::str_from_iter, and glsp::tab_with_capacity.

Alternatively, we provide convenience macros for constructing each type of collection, in the same spirit as Rust's vec![] macro.

arr! constructs a new array, returning it as a Root<Arr>:


#![allow(unused_variables)]
fn main() {
arr![];
arr![elem; n]; //elem, repeated n times
arr![100i32, 20u8, "hello", arr![]]; //types are converted using the ToVal trait
arr![a, ..src, b]; //splays the contents of src using the Splay trait
}

str! has the same syntax as Rust's format! macro, but it returns a Root<Str> rather than a String:


#![allow(unused_variables)]
fn main() {
str!("");
str!("hello, str");
str!("{}, {}", "hello", glsp::sym("str")?);
}

tab! constructs a new table from a number of key-value pairs. Optionally, it can clone another table and then insert each of the key-value pairs into it, similar to Rust's struct update syntax:


#![allow(unused_variables)]
fn main() {
let empty_tab = tab! { };

let base_tab = tab! {
	(glsp::sym("a")?, 1),
	(glsp::sym("b")?, 2)
};

let extended_tab = tab! {
	(glsp::sym("b")?, 20),
	(glsp::sym("c")?, 30),
	..base_tab
};

assert!(extended_tab.len() == 3);
}

Conversion to a Val is fallible: the arr! and tab! macros will panic if you pass them something which can't be represented as a Val, like std::u32::MAX. In the unlikely event that you need to catch this type of error, the try_arr! and try_tab! macros are identical to their counterparts, except that they return a GResult<Root<_>>.

Deques

Within GameLisp, arrays and strings both support the deque interface, so that it's possible to write a function which is generic over both types. The same is true for their Rust API.

The relevant traits are DequeOps, DequeAccess and DequeAccessRange. You'll need to make sure those traits are in scope (perhaps by importing the prelude) when working with arrays or strings.

The DequeAccess and DequeAccessRange implementations are generic over all of Rust's built-in integer types, so there's no need to convert indexes to usize. Negative indexes count backwards from the end of the deque.


#![allow(unused_variables)]
fn main() {
let n: f32 = arr.get(30_u8)?;
arr.set(-1, n * 2.0)?;
}

Type Erasure

If you need to define a variable, field or non-generic function argument which can be either a Root<Arr> or a Root<Str>, you can use the Deque enum. It implements the DequeOps, DequeAccess and DequeAccessRange traits, so you don't need to unwrap it in order to manipulate its contents.


#![allow(unused_variables)]
fn main() {
fn push_one_pop_one(deq: Deque, to_push: Val) -> GResult<Val> {
	deq.push(to_push)?;
	deq.pop_start()?;

	Ok(())
}
}

There are similar enums for GameLisp's other abstract types:

  • Num stores an i32 or f32. It supports all of Rust's arithmetic operators, and also a subset of Rust's standard API for integer types, like the div_euclid() method.

  • Callable stores those primitive types which can receive a GameLisp function call.

  • Expander stores those primitive types which can be used as a macro expander.

  • Iterable stores those primitive types which can be used to construct a GIter.

Iteration

Collections provide three separate iteration methods.

iter() is an infallible iterator which returns the collection's natural type: Val for Arr, char for Str and (Val, Val) for Tab.

iter_to<T>() performs type conversion from its natural type to T. Because type conversion is fallible, the iterator's Item type is GResult<T>, so each item will need to be individually unwrapped.


#![allow(unused_variables)]
fn main() {
let mut counter = 0u32;
for n in arr.iter_to::<u32>() {
	counter += n?;
}
}

giter() allocates and returns a new GameLisp iterator, Root<GIter>, over the collection.

Due to some limitations in Rust's type system, it's not possible to call iteration methods on a Tab directly. Instead, you'll need to use the entries() adapter method: for example, tab.entries().iter() or tab.entries().keys_to::<u32>().

Cloning

The clone() method name has already been claimed by Rust. If you call clone() for a Root<Arr>, it will create a new Root which refers to the same array, rather than cloning the array itself.

When you need to clone the contents of a collection, the correct methods are shallow_clone() and deep_clone().

Rust Functions

As we mentioned several times throughout Section 1, Rust functions can be bound to GameLisp values. Their primitive type is rfn.

In order to bind a Rust function to a GameLisp global variable, call glsp::bind_rfn:


#![allow(unused_variables)]
fn main() {
use glsp::prelude::*;

fn print_them(arg: u8, tuple: (Sym, Num), st: &str) -> [f32; 3] {
	prn!("{:?} {:?} {:?}", arg, tuple, st);
	[10.5, 21.0, 42.0]
}

glsp::bind_rfn("print-them", rfn!(print_them))?;
}
(let result (print-them 1 '(my-sym 5.0) "hello"))
(prn result) ; prints (10.5 21.0 42.0)

Alternatively, you can use the glsp::rfn function to construct an rfn value directly. They're represented by the RFn struct, which is a small Copy type, similar to Sym.


#![allow(unused_variables)]
fn main() {
let int_printer: RFn = glsp::rfn(rfn!(|n: i32| prn!("{}", n)));
arr.push(int_printer)?;
}

The glsp::rfn and glsp::bind_rfn functions accept an argument of type WrappedFn. This should be constructed using the rfn!() macro, which takes the name of a function, the full path to a method, or a non‑capturing closure, and uses some type-system wizardry to produce a type-erased wrapper function.


#![allow(unused_variables)]
fn main() {
glsp::bind_rfn("swap-bytes", rfn!(i32::swap_bytes))?;
}

Type Conversions

Return types and parameters are automatically converted to and from GameLisp values.

Types which can be produced from GameLisp function arguments implement the MakeArg trait. You're not able to implement this trait directly for your own types, but it has a blanket implementation for any type which implements FromVal.

Built-in MakeArg implementations include:

  • All of Rust's primitive integer and floating-point types.

  • Types which correspond to a GameLisp primitive type: (), bool, char, Sym, RFn, Root<Arr>.

  • Num, Deque, Callable, Iterable.

  • Tuples (A, B), fixed-size vectors [A; n], Vec, VecDeque and SmallVec, which are collected from an array. For tuples and fixed-size vectors, the length of the input array must exactly match the length of the type.

  • HashMap and BTreeMap, which are collected from a table.

  • String, CString, OsString, PathBuf, &str, &Path, all of which are copied from a GameLisp string. Note that types like &str and &Path copy the string into a temporary variable, rather than borrowing its internal storage.

  • References to types which can be pointed to by a Root: &Arr, &Str, &GFn.

Return types which can be converted to a GameLisp value implement the IntoResult trait. This trait is implemented for any T or GResult<T> where T implements ToVal. The built-in ToVal implementations are mostly the same types listed above.

Custom Type Conversions

It's possible to use your own value types (not reference types) as rfn return values or parameters by implementing the ToVal and FromVal traits.


#![allow(unused_variables)]
fn main() {
use glsp::prelude::*;

struct Coords {
	x: i32,
	y: i32
};

//a more efficient implementation is possible, as we'll see later
impl FromVal for Coords {
	fn from_val(val: &Val) -> GResult<Coords> {
		match val {
			Val::Obj(obj) => {
				let class: Root<Class> = glsp::global("Coords")?;
				ensure!(obj.is(&class), "expected Coords, received an obj");
				Ok(Coords {
					x: obj.get("x")?,
					y: obj.get("y")?
				})
			}
			val => bail!("expected Coords, received {}", val.a_type_name())
		}
	}
}

impl ToVal for Coords {
	fn to_val(&self) -> GResult<Val> {
		let class: Root<Class> = glsp::global("Coords")?;
		glsp::call(&class, &(self.x, self.y))
	}
}

fn offset(coords: Coords, dx: i32, dy: i32) -> Coords {
	Coords {
		x: coords.x + dx,
		y: coords.y + dy
	}
}

glsp::bind_rfn("offset", rfn!(offset))?;
}

Optional and Rest Parameters

Parameters of type Option<T> are treated as optional. They must appear together, after any non-optional parameters. When an optional parameter receives an argument, it's set to Some; otherwise, it's set to None.

The final parameter may be a &[T] or &mut [T] slice, in which case it will accept zero or more arguments.


#![allow(unused_variables)]
fn main() {
fn example(non_opt: u8, opt: Option<u8>, rest: &[u8]) {
	prn!("{:?} {:?} {:?}", non_opt, opt, rest);
}

glsp::bind_rfn("example", rfn!(example))?;
}
(example)         ; error: too few arguments
(example 1000)    ; error: type mismatch
(example 1)       ; prints 1 None []
(example 1 2)     ; prints 1 Some(2) []
(example 1 2 3)   ; prints 1 Some(2) [3]
(example 1 2 3 4) ; prints 1 Some(2) [3, 4]

Errors

To return an error from an rfn, you can simply set the function's return type to GResult<T>, which is an alias for Result<T, GError>.

The usual way to trigger a GameLisp error is using the macros bail!() and ensure!(). bail constructs a new GError and returns it. ensure tests a condition and calls bail when the condition is false. (The names of these macros are conventional in Rust error-handling libraries, such as error-chain and failure.)

If you need to create an error manually, you can use the error!() macro, or one of GError's constructor methods. An arbitrary Error type can be reported as the cause of an error using the with_source method.


#![allow(unused_variables)]
fn main() {
fn file_to_nonempty_string(path: &Path) -> GResult<String> {
	match std::fs::read_to_string(path) {
		Ok(st) => {
			ensure!(st.len() > 0, "empty string in file {}", path);
			Ok(st)
		},
		Err(io_error) => {
			let glsp_error = error!("failed to open the file {}", path);
			Err(glsp_error.with_source(io_error))
		}
	}
}
}

If a panic occurs within an rfn's dynamic scope, the panic will be caught by the innermost rfn call and converted into a GResult. The panic will still print a message to stderr when it occurs, including a Rust stack-trace when the RUST_BACKTRACE environment variable is set. If this is undesirable, you can override the default printing behaviour with a custom panic hook.

RFn Macros

Both Rust functions and GameLisp functions can be used as GameLisp macros (although GameLisp functions are usually the much more convenient choice).

Within a Rust function, macro_no_op!() will create and return a special kind of GError which suppresses further expansion of the current macro. (Incidentally, this is also how the macro-no-op built-in function works.)

This means that you can only use macro_no_op!() in a function which returns GResult.

Rust Data

It's possible to move arbitrary Rust data onto the GameLisp heap, so that GameLisp can interact with it directly. The primitive type for Rust data in GameLisp is rdata, and it's represented in the Rust API using the RData struct. A Root<RData> can be used as an argument to, or the return value from, a Rust function.

(let sprite (engine:load-sprite "goblin.png"))
(ensure (rdata? sprite))
(engine:draw sprite @x @y)

To move a value onto the GameLisp heap, simply call glsp::rdata(your_value). It will return a Root<RData> - a shared reference to your_value's new memory location on the heap.

glsp::rdata requires its argument to implement the RStore trait. You could implement this manually, but it's usually much more convenient to use the rdata! macro, which also generates a few extra trait implementations to enable some of the useful features described below.


#![allow(unused_variables)]
fn main() {
rdata! {
	struct Sprite {
		name: Sym,
		texture: ggez::graphics::Image
	}
}
}

The RData Type

RData is essentially a wrapper for RefCell<Option<Rc<dyn Any>>>, where the dyn Any is actually a RefCell<T>.

This may seem a little baroque, but it gives RData a lot of nice features. In exchange for a few words of memory overhead, you automatically get Any‑style dynamic typing and RefCell‑style internal mutability:


#![allow(unused_variables)]
fn main() {
let rdata: Root<RData> = Root::clone(&sprite_bank.sprites["goblin"]);
let mut sprite = rdata.borrow_mut::<Sprite>();
sprite.name = glsp::sym("goblin")?;
}

If an RData is not currently borrowed, you can remove it from the GC heap and take back ownership using the take method. Any future attempts to refer to that rdata from GameLisp code will gracefully fail. This means that if you want to clean up some Rust data, you're not left at the mercy of the garbage collector.


#![allow(unused_variables)]
fn main() {
let rdata: Root<RData> = sprite_bank.sprites.remove("goblin").unwrap();
let sprite: Sprite = rdata.take()?;
drop(sprite);
}

It's not always convenient to store a dynamically-typed Root<RData> when you know that the RData actually belongs to a specific concrete type. The RRoot<T> type behaves like a Root<RData>, but without the type-erasure.


#![allow(unused_variables)]
fn main() {
//dynamically-typed, sometimes inconvenient
struct SpriteBank {
	sprites: HashMap<String, Root<RData>>
}

//more strongly-typed, more convenient
struct SpriteBank {
	sprites: HashMap<String, RRoot<Sprite>>
}
}

The rdata! macro will automatically implement MakeArg for shared and mutable references to your type, and IntoResult for the type itself. This enables it to seamlessly participate in function type conversions:


#![allow(unused_variables)]
fn main() {
fn load_sprite(path: &Path) -> Sprite {
	//the return value will be promoted to the heap using glsp::rdata()
	...
}

fn draw_sprite(engine: &mut Engine, sprite: &Sprite) {
	//the sprite's `RData` will be borrowed for the duration of the function
	...
}

impl Sprite {
	fn name(&self) -> Sym {
		self.name
	}
}

glsp::bind_rfn("engine:load-sprite", rfn!(load_sprite))?;
glsp::bind_rfn("engine:draw-sprite", rfn!(draw_sprite))?;
glsp::bind_rfn("engine:sprite-name", rfn!(Sprite::name))?;
}

Even better, the rdata! macro allows you to add methods and properties to your struct. They behave just like the methods and properties on a GameLisp object.


#![allow(unused_variables)]
fn main() {
rdata! {
	#[derive(Clone)]
	struct Sprite { 
		/* as above */
	}

	meths {
		get "name": Sprite::name,
		set "name": Sprite::set_name,
		get "width": Sprite::width,
		get "height": Sprite::height,
		"count-pixels": Sprite::count_pixels,
		"op-clone": Sprite::clone
	}
}

impl Sprite {
	fn name(&self) -> Sym {
		self.name
	}

	fn set_name(&mut self, new_name: Sym) {
		self.name = new_name;
	}

	fn width(&self) -> u16 {
		self.texture.width()
	}

	fn height(&self) -> u16 {
		self.texture.height()
	}

	fn count_pixels(&self) -> u32 {
		(self.width() as u32) * (self.height() as u32)
	}
}
}

(let sprite (engine:load-sprite "goblin.png"))
(ensure (== (.count-pixels sprite) (* [sprite 'width] [sprite 'height])))

(= [sprite 'name] 'changeling)
(prn [sprite 'name]) ; prints changeling

; rdata can be indexed using iterables
(prn ..[sprite '(width height)])

; rdata participate in operator overloading
(let cloned (clone sprite))

You can query whether an rdata belongs to a particular Rust type by calling, for example, (is? rdata 'Sprite). The last argument should be a symbol which is identical to the name of your struct. That same symbol will be returned if you call (class-of rdata).

Because RData's classes are represented by a symbol, it's an error if you allocate RData of multiple distinct types which share the same name. For example, you couldn't have both audio::Clip and video::Clip, even though their fully-qualified names are different.

Internal References

Rust's type system will statically prevent Root<T> and RRoot<T> from being stored in an RData. This is inconvenient, but necessary - storing a root on the garbage-collected heap would be very likely to lead to memory leaks.

For the time being, you'll need to store your Root<T> in a library instead, and refer to it indirectly - for example, using an integer ID.


#![allow(unused_variables)]
fn main() {
rdata! {
	//type error
	struct PhysicsBox {
		owner: Root<Obj>,
		coords: Rect
	}

	//better
	struct PhysicsBox {
		owner_id: u32,
		coords: Rect
	}
}
}

Multithreading

GameLisp is single-threaded, because multithreading is primarily a performance optimization. Nobody is writing multithreaded game code for its beauty or its convenience! GameLisp code simply isn't fast enough for multithreading to be worth the extra complexity it would add to the language, so it's not supported.

That being said, GameLisp is designed to be cleanly embedded into a multithreaded Rust program. You can spin up a separate isolated Runtime for each thread if you like (similar to a Web Worker), but it would be more typical to have one Runtime which lives on the main thread, with a few worker threads which only run Rust code.

Let's imagine you have a Clip which stores a few seconds of PCM audio data, and a worker thread which performs audio mixing in software. You want to load and manipulate Clips from your GameLisp scripts, while still allowing the worker thread to access their PCM samples. A naive attempt would look something like this:


#![allow(unused_variables)]
fn main() {
rdata! {
	struct Clip {
		name: Sym,
		channels: Channels,
		samples: Vec<i16>
	}
}
}

You'll run into a problem when you try to pass your Clip to your worker thread. Because the Clip contains a Sym, which is non-Send, you can't transfer it to another thread. In any case, you can only refer to the Clip using the types Root<RData>, RRoot<Clip>, &Clip, and &mut Clip, none of which can be freely sent between threads.

Thanks to fearless concurrency, there's an easy fix. Simply wrap the shared parts of your struct in an Arc<T>:


#![allow(unused_variables)]
fn main() {
struct Samples {
	channels: Channels,
	samples: Vec<i16>
}

rdata! {
	struct Clip {
		name: Sym,
		samples: Arc<Samples>
	}
}

impl Clip {
	fn play(&self, mixer: &Mixer) {
		mixer.play_samples(Arc::clone(&self.samples));
	}
}
}

Ideally, when writing a multithreaded game with GameLisp, your worker threads shouldn't know that GameLisp exists at all. Think of each worker thread's code as a small library written in pure Rust, for which your main thread is a client. In the example above, the Mixer could be thought of as a library which mixes raw sample buffers, not a library which mixes Clips specifically.

Libraries

As a Rust game developer, you're probably already aware of Rust's stern and disapproving attitude towards global variables.

In order to prevent unsafe mutation and unsafe multithreading, safe Rust is forced to completely forbid global mutable variables. They need to be wrapped in a thread_local!, a RefCell, a Mutex, a lazy_static!, or something else to shield you from the shared mutable data.

This is strictly necessary to uphold Rust's invariants, but it always makes global variables much less convenient to use. Typical use of a thread_local! variable is not a pretty sight:


#![allow(unused_variables)]
fn main() {
thread_local! {
	pub(crate) static LOG_FILE: RefCell<Option<File>> = RefCell::new(None);
}

fn log_str(text: &str) {
	LOG_FILE.with(|ref_cell| {
		let mut option = ref_cell.borrow_mut();
		if let Some(ref mut file) = *option {
			file.write_all(text.as_bytes()).ok();
		}
	})
}
}

Contrast the equivalent C code:

thread_local FILE* log_file;

void log_str(const char* text) {
	if (log_file) {
		assert(fputs(text, log_file));
	}
}

When programming a game, you'll sometimes encounter a part of your engine which seems "naturally global" - a texture manager, an audio mixer, an entity database. There's only ever going to be one of it, and it's only ever going to be accessed from a single thread, but Rust still forces you to keep it at arm's length.

You're given two options:

  • Use something like a lazy_static! RwLock. At minimum, this requires you to recite the incantation NAME.read().unwrap() every time you access the global object. This is inconvenient, it makes the order of initialization/destruction less predictable, and it carries a non‑trivial performance cost.

  • Allocate your "global" object on the stack when your program starts up, and pass borrowed references down the callstack to any function which needs to access it. I would consider this an anti‑pattern in Rust game development - passing a context reference into most function calls adds a lot of visual noise, and it sometimes causes borrow‑checker headaches.

    • This is why functions like glsp::sym are free functions, rather than being invoked as methods on some &mut Glsp "God object". An earlier version of the glsp crate did work that way, but it made the library much less pleasant to use.

If neither of these options seem appealing, GameLisp offers an alternative.

Libraries

A "library" is a Rust object which is owned by a GameLisp Runtime. Unlike RData, libraries are singletons: for a given library type, you can only register one instance of that type in each Runtime.

Libraries must implement the Lib trait. You can implement Lib manually, but it's usually better to use the lib! macro.

To add an instance of a library to a Runtime, call glsp::add_lib, and to remove it, call glsp::take_lib. To temporarily borrow the library from the active Runtime, simply call T::borrow or T::borrow_mut, where T is the library's type.


#![allow(unused_variables)]
fn main() {
lib! {
	struct Textures {
		map: HashMap<Sym, RRoot<Texture>>
	}
}

fn init_textures() -> GResult<()> {
	glsp::add_lib(Textures::new())?;

	Ok(())
}

fn texture(id: Sym) -> GResult<RRoot<Texture>> {
	match Textures::borrow().map.get(&id) {
		Some(texture) => Ok(RRoot::clone(texture)),
		None => bail!("texture {} does not exist", id)
	}
}
}

Dynamic checks are used to uphold Rust's aliasing rules - for example, it's an error to call glsp::take_lib or T::borrow_mut for a library which is currently borrowed. When the Runtime is dropped, each of its libraries will be dropped in the reverse order that they were registered.

This is already a big improvement compared to lazy_static!, but the real magic comes from function type conversions. When a function parameter is a shared or mutable reference to a library type, calling the function from GameLisp will automatically borrow that library for the duration of the function call. If the texture() function above was intended to be called from GameLisp, we could rewrite it like this:


#![allow(unused_variables)]
fn main() {
impl Textures {
	fn texture(&self, id: Sym) -> GResult<RRoot<Texture>> {
		match self.map.get(&id) {
			Some(texture) => Ok(RRoot::clone(texture)),
			None => bail!("texture {} does not exist", id)
		}
	}
}

glsp::bind_rfn("texture", rfn!(Textures::texture))?;
}

The nice thing about this style of function binding is that the Texture::texture method is equally usable from both GameLisp and Rust. There's no need to maintain a separate set of "wrapper functions" - GameLisp handles the type translations for you.

Even generic functions are supported! You just need to select a single concrete type signature when you pass the binding to glsp::bind_rfn:


#![allow(unused_variables)]
fn main() {
impl Textures {
	fn texture<S: ToSym>(&self, id: S) -> GResult<RRoot<Texture>> {
		let sym = id.to_sym()?;

		match self.map.get(&sym) {
			Some(texture) => Ok(RRoot::clone(texture)),
			None => bail!("texture {} does not exist", sym)
		}
	}
}

glsp::bind_rfn("texture", rfn!(Textures::texture::<Sym>))?;
}

Library parameters, like the &self parameter above, don't consume an argument. You would invoke this function from GameLisp by calling (texture id), with only one argument.

A function may have multiple library parameters:


#![allow(unused_variables)]
fn main() {
impl Textures {
	fn draw_texture(
		&self,
		renderer: &mut Renderer,
		id: Sym,
		x: i32,
		y: i32
	) -> GResult<()> {
		match self.map.get(&id) {
			Some(texture) => renderer.draw(texture, x, y),
			None => bail!("texture {} does not exist", id)
		}
	}
}

glsp::bind_rfn("draw-texture", rfn!(Textures::draw_texture))?;
}

Assuming Renderer is a library type, you would invoke the above function from GameLisp as (draw-texture id x y).

Symbol Caching

If you're finicky about performance, function calls like glsp::global("texture-count") might make you nervous. That function will invoke glsp::sym("texture-count") every time it's called, performing a hash-table lookup to convert the string into a symbol. Surely it would be better to cache the symbol somewhere, and call glsp::global(TEXTURE_COUNT_SYM) instead?

(In 90% of cases, the answer to that question is "no, it doesn't matter"... but the last 10% can be quite important!)

Symbols can't be stored in static variables, because they're unique to a particular Runtime. If you want to cache a symbol as a global variable, it should be stored in a library instead.


#![allow(unused_variables)]
fn main() {
struct Textures {
	//...

	pub nearest_neighbour_sym: Sym,
	pub bilinear_sym: Sym,
	pub trilinear_sym: Sym
}

impl Textures {
	fn new() -> GResult<Textures> {
		Textures {
			//...

			nearest_neighbour_sym: glsp::sym("nearest-neighbour")?,
			bilinear_sym: glsp::sym("bilinear")?,
			trilinear_sym: glsp::sym("trilinear")?
		}
	}
}
}

We provide the syms! macro to make this more straightforward. The macro defines a struct for which every field is a Sym, with a constructor function named new() which initializes each field by calling glsp::sym, returning a GResult.


#![allow(unused_variables)]
fn main() {
syms! {
	pub (crate) struct Syms {
		pub(crate) nearest_neighbour: "nearest-neighbour",
		pub(crate) bilinear: "bilinear",
		pub(crate) trilinear: "trilinear"
	}
}

struct Textures {
	syms: Syms,

	//...
}

impl Textures {
	fn new() -> GResult<Textures> {
		Textures {
			syms: Syms::new()?,

			//...
		}
	}
}
}

Multithreading

Because libraries are owned by a GameLisp Runtime, they're inherently single-threaded. There's no way to simultaneously refer to the same library from multiple threads.

This is usually what you want. Multithreading is complicated (even in Rust!), and it carries a performance impact. It can be quite liberating to know for a fact that Textures::draw_texture won't wait for another thread, trigger a deadlock, or waste a few dozen CPU cycles locking a Mutex.

As discussed in the previous chapter, your escape hatch is Arc. If you decide to use concurrency to optimize some part of your library, you can wrap it in an Arc<T> or Arc<Mutex<T>>, and share only that part of the library between threads.

Garbage Collection

GameLisp provides automatic memory management, in the form of a tracing garbage collector (GC). This means that there's no need to worry about cyclic references - orphaned reference cycles will be collected automatically.

Traditional tracing GCs wait until a certain amount of memory has been allocated, and then attempt to scan or deallocate a large amount of memory all at once. Even incremental or concurrent GCs tend to wait for memory pressure to reach a certain level, switch themselves on for a while, and then switch themselves off - examples include Unity's upcoming incremental collector, and the garbage collector currently provided by Lua and LuaJIT.

None of these options are the right choice for game development. In order for a fast-paced game to avoid feeling "choppy", it must meet the deadline set by the monitor's refresh rate, or an integer fraction of that refresh rate (say, 72 Hz for a 144 Hz monitor). A swap chain can allow the game to accumulate one or two frames of "borrowed time" without being detectable to the user (at the cost of increased latency), but whenever the cumulative delay exceeds those one or two frames, the debt is cashed in and the user will experience a frameskip.

(This is a simple thing to test - in your main loop, just sleep for 15ms every 40th frame, which is equivalent to exceeding a 60 Hz frame budget by about 0.3ms per frame. If your game involves any fast movement, you'll find that it suddenly "feels wrong", even if you can't pinpoint exactly which frames are being skipped. Note that this test may not work if your swap chain is triple-buffered.)

When a game's GC is allowed to stop and start, and it takes up one millisecond per frame while it's running, then the time budget is effectively one millisecond lower for every frame. If a particular scene normally runs at 16 milliseconds per frame, then when the garbage collector switches on it will suddenly take up 17 milliseconds and start skipping frames. In this scenario, you'd be forced to set aside 6% of your entire time budget just for the GC!

The status quo is usually even worse - when a GC is allowed to "stop the world", it might pause your game for 50 milliseconds or more. I've heard anecdotal reports of games experiencing GC pauses of several hundred milliseconds. This would be clearly noticeable even in a slow, casual game.

GameLisp's GC

GameLisp's solution is a custom incremental GC algorithm which runs once per frame, every frame. The amount of work it performs increases in lockstep with the amount of new memory allocated during the previous frame (which, for a typical game, should be very consistent).

It turns out that when you spread out the work like this, garbage collection is extremely cheap. Appendix A has some concrete performance numbers, but the headline figure is that for a 2D sidescroller running on a mid-range laptop, the GC uses about 0.1 milliseconds per frame, or 0.2 milliseconds for a really busy scene.

GameLisp will never invoke the GC behind your back. You need to do it explicitly, either by calling the glsp::gc function from Rust, or the gc function from GameLisp.

For a typical main loop, you should simply invoke the GC once per frame. If you're scripting a program with an unusual main loop (say, a GUI event loop for your level editor), then you should aim to invoke the GC about sixty times per second, although it's fine to temporarily stop when your program isn't executing any GameLisp code.

There's currently only one way to tune the GC's behaviour: calling glsp::gc_set_ratio or (= (gc-value 'ratio) r) to assign a "heap ratio". This is the ratio between the average size of the GC heap, and the amount of long-lived memory which it stores. The default value is 1.5, so if you store 10mb of useful long-lived data on the heap, it will also contain about 5mb of garbage.

When you set the ratio to a lower value, the GC will need to perform an exponentially higher amount of work to keep up. The minimum ratio is currently 1.2.

Procedural Macros

The glsp crate defines a handful of procedural macros. In general, their purpose is to blur the line between GameLisp code and Rust code.

At the time of writing, nightly Rust requires #![feature(proc_macro_hygiene)] in order for procedural macros to be used in expression position. This restriction will be lifted in the very near future.

eval

The eval! procedural macro takes a string literal which contains GameLisp source code, and executes it. You can interleave local variables into the evaluation by unquoting them with ~. Local variables are converted to and from GameLisp values using the ToVal and FromVal traits.


#![allow(unused_variables)]
fn main() {
let width: i32 = 100;
let height: i32 = 200;
let mut area: i32 = 0;

let _: Val = eval!("(= ~area (* ~width ~height))")?;
}

You can split the string literal over several lines, and include embedded strings, using a raw string literal:


#![allow(unused_variables)]
fn main() {
let y: usize = 20;
let _: Val = eval!(r#"
  (let x (* ~y 10))
  (prn "the value of y is {(/ x 10)}")
"#)?;
}

eval! is much faster than the eval and glsp::eval functions. While your crate is compiling, it fires up a GameLisp Runtime and uses it to compile the the literal string into GameLisp bytecode, which is lazily loaded into your own Runtime when the eval! is first executed. This means that, unlike the alternatives, eval! does not need to compile any code when it's executed.

One small downside is that, because the code is expanded and compiled using a generic, empty Runtime, it can't make use of any macros except those defined in the GameLisp standard library. However, it can still access your custom GameLisp functions, Rust functions and global variables as normal.

Because it performs bytecode serialization, the eval! macro is only available when the "compiler" feature flag is enabled.

quote

The quote! macro is equivalent to the quote form. It takes a text description of some GameLisp data, parses it at compile time, lazily allocates and deep-freezes it the first time the quote! is executed, and then repeatedly returns the same data every time it's executed. This is sometimes more efficient than recreating the data from scratch.

Its return type is generic, so you should usually assign it to a variable with a concrete type, such as Val or Root<Arr>.


#![allow(unused_variables)]
fn main() {
let ai_priorities: Root<Arr> = quote!(r#"
	(harvest-materials defend-self guard-allies build-structures)
"#);
creature.set("ai-priorities", ai_priorities)?;
}

backquote

backquote! is equivalent to the backquote form. It emits code to allocate a fresh, mutable copy of the specified GameLisp value, perhaps interleaving local variables into the output. It can be useful when implementing a GameLisp macro as a Rust function.

Local variables can be splayed, but otherwise it's not yet possible to evaluate arbitrary Rust code within a backquote!.


#![allow(unused_variables)]
fn main() {
fn test_numbers_macro(attempt: Val) -> Val {
	let numbers: [i32; 4] = [36, -10, 59, 97];
	backquote!(r#"
		(cond 
		  ((eq? ~attempt '(~..numbers))
		    (prn "I have a bad feeling about this..."))
		  (else
		    (prn "Hint: one of the numbers is " (rand-select ~..numbers))))
	"#)
}
}

Unquoted local variables are converted into GameLisp values using the ToVal macro, which has the potential to fail. backquote! will panic if the conversion fails. try_backquote! is the non-panicking equivalent; it returns a GResult<T>.

Compilation

GameLisp code is quite fast to load. The Castle on Fire's codebase is currently around 800 kilobytes of idiomatic GameLisp code (15 KLoC plus comments), which is completely loaded in 550 milliseconds. For most games, it should be fine to simply call glsp::load at startup.

However, if you're keen to improve startup performance, GameLisp supports pre-compilation of its source code into a binary format, similar to Lua's binary chunks or Python's py_compile module. Because this skips macro-expansion, it's much faster than glsp::load: The Castle on Fire's source, when pre-compiled, loads in less than 100 milliseconds.

Compiling your source code can also help to obfuscate it. When you use compiled code, there's no need to distribute the original source files alongside your executable. Reverse-engineering the GameLisp binary format into readable GameLisp code would be a challenge, to say the least.

Note that compiled GameLisp code does not run faster than uncompiled code. The final result is the same - the only difference is how the code is loaded into memory.

Because compilation relies on the serde and bincode crates, it's hidden behind the feature flag "compiler", which is disabled by default.

The compile! macro

The easiest way to precompile GameLisp code is using the compile! macro.

This is a procedural macro, so it runs while rustc is executing. It starts up a generic, empty GameLisp runtime, uses it to compile all of the GameLisp source files which you specify, and embeds the result directly into the Rust executable (like include_bytes!), returning it as a &'static [u8].

That byte slice can then be passed to glsp::load_compiled to run it.


#![allow(unused_variables)]
fn main() {
//these two calls are roughly equivalent
glsp::load_compiled(compile!["scripts/main.glsp"])?;
glsp::load("scripts/main.glsp")?;
}

The main downside of using compile! is that, because your scripts are no longer being loaded from the filesystem, the only way to change them is to run rustc again, which is slow and inconvenient. Therefore, you should generally only use compile! when producing your final binary for distribution:


#![allow(unused_variables)]
fn main() {
#[cfg(feature = "compiler")]
glsp::load_compiled(compile!["scripts/main.glsp"])?;

#[cfg(not(feature = "compiler"))]
glsp::load("scripts/main.glsp")?;
}

The glsp::load_and_compile function

compile! is very convenient, but it always compiles your source files using an empty, generic GameLisp runtime. This runtime will have access to the GameLisp standard library and any macros you define using bind-macro! or defmacro, but it won't run any of your Rust initialization code, so it won't have access to any libraries, any Rust functions, any assignments you've made to global variables from within Rust code, and so on.

This will only matter if you rely on one of your libraries, or call one of your Rust functions, in either of the following circumstances:

  • When evaluating a toplevel form (see Evaluation)
  • When running a macro expander

In the unlikely event that this is the case, your Rust program can perform its usual setup, then compile GameLisp code manually by calling glsp::load_and_compile. This will return a GResult<(Val, Vec<u8>)>, where the Val is the result of loading and running the file, and the Vec<u8> is the result of compiling it.

It's straightforward to serialize a Vec<u8> to a file. The next time your program runs, you can read that Vec<u8> back in, and pass it to glsp::load_compiled as a byte slice.

The GameLisp binary format has absolutely no stability guarantees. If you recompile your executable, then you must also recompile any GameLisp binaries which that executable has produced in the past. Consider writing a build script which deletes any saved binaries.

Corner Cases

GameLisp code is different from Lua or Python code, because it has a macro-expansion pass. It's also different from Scheme and Rust, because those macros are defined dynamically - the set of bound macros can change between one toplevel form and the next.

Macro-expansion is expensive, so the only way to efficiently compile GameLisp code is to expand it before compiling it. This means that when you call glsp::load_compiled, any macros in the current GameLisp runtime will be ignored. All that matters is which macros were present in the runtime which compiled the code.

Unfortunately, because of the dynamic binding mentioned above, the only way to macro-expand GameLisp code is to run it. This is why the function is glsp::load_and_compile rather than just glsp::compile; one way of thinking about it is that we're loading the file (and all of the files which it loads in turn), running it, and "recording" the execution in a format which can be "played back" in the future.

99% of the time, you won't have to think about this. It's only relevant if the GameLisp environment which calls glsp::load_and_compile somehow differs from the GameLisp environment which calls glsp::load_compiled - because then the expected "playback" will differ from the actual "recording", and panics or logic bugs may occur.

; because you're loading different files on different run-throughs, an 
; error will occur if you compile this form on a linux machine and then 
; run it on a non-linux machine.
(cond
  (on-linux?
    (load "linux.glsp"))
  (else
    (load "non-linux.glsp")))

; this macro expands to a constant value representing the screen width 
; *at the time of macro expansion*. if this happens to be different between 
; your own machine and the user's machine, the value will be incorrect.
(defmacro screen-w ()
  (my-window-library:screen-width))

; because you're calling an rfn, this form will trigger an error if you pass
; it to the compile![] macro, rather than glsp::load_and_compile.
(my-sound-library:init)

The simplest way to protect yourself against this is to use compile! rather than glsp::load_and_compile, and perform all of your loading immediately after calling Runtime::new, before binding libraries or doing anything else which might modify the Runtime. This means that you won't be able to access libraries or Rust functions from the toplevel or from macro expanders - but you will still be able to access them from within normal GameLisp functions.

; this is fine, because it's a fn rather than a macro
(defn screen-w ()
  (my-window-library:screen-width))

; this is fine, as long as (init-sound) isn't called from the toplevel or 
; from a macro expander
(defn init-sound ()
  (my-sound-library:init))

Feature Flags

By default, the glsp crate's only transitive dependencies are smallvec, owning_ref, stable_deref_trait, fnv, and the Rust standard library.

$ cargo tree
glsp v0.1.0
+-- glsp-engine v0.1.0
|   +-- fnv v1.0.7
|   +-- owning_ref v0.4.1
|   |   +-- stable_deref_trait v1.1.1
|   +-- smallvec v1.4.0
+-- glsp-proc-macros v0.1.0
|   +-- glsp-engine v0.1.0 (*)
+-- glsp-stdlib v0.1.0
	+-- glsp-engine v0.1.0 (*)
	+-- glsp-proc-macros v0.1.0 (*)
	+-- smallvec v1.4.0 (*)

All large or non-essential dependencies are feature-gated, and all features are disabled by default.

"unsafe-internals"

By default, glsp's implementation doesn't use any unsafe code at all. This is guaranteed using #![forbid(unsafe_code)].

With the "unsafe-internals" feature enabled, a small amount of unsafe code is switched on in the glsp-engine crate. This makes the interpreter run roughly twice as fast.

Note that glsp's public API is always intended to be safe, even when the "unsafe-internals" feature is enabled. The purpose of this feature flag is to mitigate the safety impact of any undetected bugs which are internal to the glsp crate.

Even with "unsafe-internals" disabled, glsp may depend on crates which themselves use unsafe internally - currently smallvec, owning_ref and optionally bincode. As usual, you shouldn't trust this crate's safety unless you also trust its dependencies.

"serde"

Introduces a dependency on the serde crate, but not serde_derive.

Implements Serialize and Deserialize for Val, Root, Arr, Tab, Str and Sym. Note that the serializer will gracefully fail if it encounters a non-representable type, or a type which contains reference cycles. Gensyms and textually-ambiguous symbols can be serialized and deserialized, even though they're not representable.

"compiler"

Introduces a dependency on the "serde" feature, as well as the crates bincode, flate2, syn, quote, proc_macro2 and serde_derive.

This feature flag enables GameLisp source code to be pre-compiled into an efficient binary format. Provides the compile! and eval! macros, and the glsp::load_and_compile and glsp::load_compiled functions. See the Compilation chapter for more information.

Performance Figures

All performance timings on this page were obtained using a mid-range laptop from 2016 with an Intel Core i7-6500U CPU (2.5 GHz). Rust code was compiled with opt-level = 3, lto = true, codegen-units = 1. The "unsafe-internals" feature was enabled unless noted otherwise.

Specimen Project

GameLisp's specimen project is The Castle on Fire, a 2D sidescroller which is still under development. We can use this project to provide some representative performance figures for a real-world game which uses GameLisp.

The project has around 15,000 non-empty, non-comment lines of GameLisp code, which are loaded into the GameLisp runtime in about 550 milliseconds (or less than 100 milliseconds when pre‑compiled).

The entity system, main loop and parts of the physics system are implemented in GameLisp. Each entity receives a minimum of two method calls per frame. In a scene with 292 (mostly trivial) entities, the total execution time spent running GameLisp code is ~2.0 milliseconds per frame, the memory pressure is 180 kilobytes of small allocations per frame, and the GC time is around 0.10 milliseconds per frame, maintaining a 7 megabyte heap.

When we switch to a very busy scene (30 physics-enabled on-screen enemies with behaviour scripting and rendering, all controlled from GameLisp), the GameLisp execution time climbs to ~4.0 milliseconds, the memory pressure to 350 kilobytes, and the GC time to 0.20 milliseconds. Most of this execution time is spent on the physics simulation - moving the hottest parts of the physics engine to Rust would be an easy optimization, if necessary.

If the heap size seems small, note that the game's total memory usage is a few dozen megabytes. The 7 megabyte heap only contains memory managed directly by GameLisp, not including rdata.

When the game is run on a low-end laptop from 2011 (with a Pentium P6100), all GameLisp code execution (including source-file loading and garbage-collection) experiences a constant-factor slowdown of around 2x to 4x relative to the i7-6500U. The game remains very playable.

Benchmarks

GameLisp uses a bytecode interpreter, so I've benchmarked it against other interpreted languages - specifically, the reference implementations of Lua and Python. Please note that JITted scripting language implementations, like LuaJIT, PyPy, V8, and SpiderMonkey, would be significantly faster. (However, integrating those libraries into a Rust game project would not be straightforward.)

The "GameLisp (SI)" column was compiled with the default feature flags, and the "GameLisp (UI)" column was compiled with the "unsafe-internals" flag enabled.

The primitive_x benchmarks perform a basic operation (like addition or array indexing) in an unrolled loop, while the remaining benchmarks try to imitate actual game code.

BenchmarkLua 5.3GameLisp (UI)Python 3.7.7GameLisp (SI)
primitive-inc432.2ms672.1ms3710.3ms1079.8ms
primitive-arith534.0ms535.1ms2298.7ms828.4ms
primitive-call0258.3ms631.9ms740.2ms1036.1ms
primitive-call3592.3ms994.0ms955.7ms1891.2ms
primitive-array76.1ms204.5ms247.4ms390.5ms
primitive-table85.8ms395.9ms269.6ms679.2ms
primitive-field82.9ms217.6ms333.2ms633.6ms
primitive-method489.1ms1275.1ms838.7ms2093.0ms
rects384.0ms1142.5ms1234.7ms2664.8ms
flood_fill400.1ms632.2ms664.3ms976.9ms
rotation657.4ms1015.3ms1325.6ms1843.9ms

By default, GameLisp's performance is inferior to Python. If you've benchmarked your GameLisp scripts and established that they're a performance bottleneck, switching on the "unsafe-internals" flag will roughly double their performance. With that flag switched on, GameLisp's performance currently hovers somewhere between Lua and Python.

Optimizing GameLisp Code

Don't.

I really mean it. Other than occasionally thinking about the big-O complexity of your algorithms, you shouldn't waste any effort at all trying to make GameLisp run fast. Please don't be tempted.

The primary reason is that, as described in Section 2, GameLisp is very closely integrated with Rust. Here's the punchline to those benchmark figures above:

BenchmarkRustGameLisp (UI)GameLisp (SI)
primitive-inc44.1ms672.1ms1079.8ms
primitive-arith126.7ms535.1ms828.4ms
primitive-call014.1ms631.9ms1036.1ms
primitive-call334.5ms994.0ms1891.2ms
primitive-array12.1ms204.5ms390.5ms
primitive-table259.5ms395.9ms679.2ms
primitive-field10.3ms217.6ms633.6ms
primitive-method10.4ms1275.1ms2093.0ms
rects9.6ms1142.5ms2664.8ms
flood_fill2.7ms632.2ms976.9ms
rotation59.4ms1015.3ms1843.9ms

Idiomatic Rust code will often be more than a hundred times faster than idiomatic GameLisp code. Even hand-optimized GameLisp code is usually dozens of times slower than a naive Rust implementation. Rust is incredibly fast.

As I mentioned at the very beginning, GameLisp is intended to be used for the messy, exploratory, frequently-changing parts of your codebase. Whenever I've been faced with a dilemma between making GameLisp fast and making it convenient, I've almost always chosen convenience.

This is why I've put so much effort into giving GameLisp a really nice Rust API. If some part of your GameLisp codebase has unacceptably poor performance, it's usually an easy task to "rewrite it in Rust", in which case you'll immediately reap huge performance gains.

Here's a quick breakdown of the parts of a game codebase which are likely to be better-suited for either Rust or GameLisp, speaking from experience:

  • For binary file-format parsing, rendering, audio mixing, image processing, vertex processing, spatial data structures, collisions, physics simulation, and particle effects, Rust is the obvious winner.

    • That being said, you can definitely manage those parts of your engine using GameLisp. Your .zip file parser might be implemented in Rust, but your resource manager could be implemented in GameLisp. Your physics simulator might be implemented in Rust, but you could also have a PhysicsRect mixin which hooks an entity into the physics system.

    • GameLisp can also be useful for prototyping. For example, if your game procedurally generates its levels, you could use GameLisp to freely experiment with different algorithms, and then reimplement the final algorithm in Rust.

  • Your main loop might belong in Rust, depending on how complicated it is.

    • If your game uses an entity-component system, the bulk of the ECS should definitely be implemented in Rust. Good performance is more-or-less the entire point of an ECS. Consider implementing a Script component which owns a GameLisp object and invokes callbacks on it - or perhaps even one component for each callback, like UpdateScript and DrawScript.
  • For entity behaviour scripting and cutscene scripting, GameLisp is the obvious choice.

Naming Conventions

This appendix describes some of the naming conventions I've developed while working on The Castle on Fire. Improvisation and experimentation are encouraged, but you might find these guidelines to be a useful starting point.

Guidelines

Prefer brevity.

The = suffix is for functions/methods which perform an assignment, the ! suffix is for functions/methods which perform any other kind of destructive mutation, and the ? suffix is for boolean variables and for functions/methods which return booleans. For conversions, consider using src->dst for a function or ->dst for a method.

lower-kebab-case is used for local variables, let-fn functions, let-macro macros, built-in functions, built-in macros, class fields, class clauses, and keyword symbols like 'ok. If you're defining a function or macro which is intended to be very global, such as a new control-flow macro or a new numeric function, you should similarly use unprefixed lower-kebab-case.

UpperCamelCase is used for classes, states and RData types. This includes mixins, structs,
local classes defined with let-class, and variables which store classes. Class names should avoid prefixes where possible: Sprite rather than GfxSprite or gfx:Sprite. Capitalization follows the same rules as for a Rust struct, e.g. HudPopup rather than HUDPopup.

An RData's constructor function should be a global variable, named to resemble a class constructor: (Sprite rgb w h), (PhysicsRect coords). If an RData or a class has multiple possible constructors, globally bind a function to the type name, plus a suffix: (Sprite:load path), (TileLayer:from-arr tiles). In general, when manipulating RData and objects, prefer methods over free functions.

Global functions and global variables are categorized into de-facto modules with very short names, like img, phys or res. Global names are prefixed with their "module": img:draw, res:load-resources. Toplevel let variables and let-fn functions are similarly prefixed, mostly to differentiate them from local variables.

(defn phys:step ()
  ...)

(let-fn phys:box-coords (box)
  ...)

A small number of ubiquitous global variables are prefixed with :, purely to make them more brief. For example, :clock for the current game-time, :dt for the "delta time" since the last tick, and :screen-w for the pixel width of the back buffer.

Implementation Limits

There are several hard limits built in to GameLisp.

There cannot be more than 16,777,216 (2^24) distinct symbols. Running out of symbols is treated as an unrecoverable error, similar to an out-of-memory condition: attempting to allocate additional symbols will trigger a panic.

There can't be more than 256 simultaneous GameLisp function calls on the callstack. Attempting to call the 257th function will trigger an error instead. This is because:

  • GameLisp implements recursion using Rust's callstack.
  • A Rust stack overflow would abort the process.
  • GameLisp function calls use up quite a lot of stack space.
  • The default stack size for *-pc-windows-msvc targets is only one megabyte.

Rust currently seems to use up a very large amount of stack space in debug builds. If you try to run the glsp crate at opt-level = 0, you may still encounter stack overflows, even when there are only a few dozen GameLisp function calls on the callstack.

Each "frame" (fn body or single toplevel form) may only contain 256 "registers" (parameters, local variables, scratch registers or literals). Exceeding this limit will trigger an error. This will usually only happen if you use macros to code-generate an extremely long function. In that case, you can break up the function into multiple frames by wrapping each part of its body in a separate fn form.

A class form may not contain more than 31 state or state* forms, including nested states and states defined by mixins.

When a function call has more than 32 arguments, the 33rd and later arguments can't be splayed.

No more than 256 Runtimes may simultaneously exist within a single thread.

Each individual GC allocation may not be simultaneously referred to by more than 8,388,607 (2^23 - 1) distinct Roots.

There are a small number of ways that a Root may outlive its parent Runtime, or be assigned to a different Runtime. In either scenario, the only way that GameLisp can uphold memory safety is by immediately aborting the process! The most likely way you might do this accidentally is by either leaking a Root (using a function like Box::leak or Rc::new), or storing a Root in a thread_local! variable.