Language Tour
Rulebook is a compiled and statically checked language.
# rlc hello world
import serialization.print
cls SimpleRegularCode:
Int x
Bool y
fun main() -> Int:
let pair : SimpleRegularCode
print(pair) # {x: 0, y: false}
return 0
Its defining innovation is the concept of action functions—a mechanism designed to handle complex interactions—enhanced with SPIN properties. Rulebook carefully combines multiple uncommon languages features in a imperative language that is more than the sum of the parts.
Action Functions
Rulebook is built to simplify the construction of complex interactive subsystems. It achieves this through SPIN functions, which encapsulate stateful, inspectable logic without taking over control flow.
SPIN stands for:
These properties allow developers to store, load, print, replay, and modify both program state and execution traces in a principled and testable way.
No-Main Loop Ownership
In interactive systems, programs must often wait for input from users or networks. Typically, such systems are governed by a framework that controls the main loop—such as a GUI or game engine—rendering frames while awaiting events.
Rulebook’s action functions are coroutines. They don’t need to control the main loop, making them inherently framework-agnostic.
Here’s an example. The say_hello()
function runs until it hits an act resume()
statement. From the main function, we resume execution using sayer.resume()
, causing the next part of the coroutine to run:
import serialization.print
act say_hello() -> HelloSayer:
act resume()
print("hello")
act resume()
print("hello")
fun main() -> Int:
let sayer = say_hello()
sayer.resume() # hello
sayer.resume() # hello
return 0
Thanks to this no-main loop ownership, you can write interactive programs as if they directly prompt the user, without needing to integrate deeply with or understand the details of the underlying framework.
Precondition Checkable
Precondition checkability means the framework that owns the main loop can proactively validate user inputs—without embedding logic directly into the action function.
Consider this installer example: it prompts the user for a valid installation path, asks whether to include experimental content, and performs the installation based on that input.
import string
act installer() -> Installer:
show_path_screen_prompt()
act select_location(frm String path) { !file_exists(path) }
show_experimental_content_prompt()
act install_experimental_content(Bool do_it)
if do_it:
install_with_extra(path)
else:
install(path)
fun main() -> Int:
let installer = installer()
let path = "wrong_path"s
if can installer.select_location(path):
installer.select_location(path)
installer.install_experimental_content(False)
return 0
In Rulebook, suspension points inside action functions are called action statements. These can take arguments and may include a boolean condition that defines when the action is valid. By using the can
operator, the framework can check whether a given user input is valid before applying it—without complicating the action function with manual validation logic.
Besides can
operator invocations, the spirit of Rulebook is to be used by other languages, so depending on your use case you configure how Rulebook behaves when a precondition is not met but a function is called anyway.
By default rulebook emits checks that invoke
rlc_abort
, a function that can be customized. The customization allows us for example to print a python stack trace when rulebook is used with full python interoperability. In our 4Hammer example we customize for linux only the stack printing mechanism so we can see what is going on inside godot.If you need maximum speed, or you know that the caller will never invoke wrong actions by construction (for example,the c# wrapper checks for preconditions too and emits a exception if they are not met), you can disable checks from within rulebook code.
This makes precondition checkability a zero cost abstraction that you pay for only when you use it.
Serializable
In many interactive systems, it’s useful—or even necessary—to save and restore program state. Rulebook supports this by allowing coroutines to be serialized and copied.
Let’s say you wrote a simple rock-paper-scissors game. After player 1 selects a move, you want to test every possible response from player 2 to find a winning one.
enum Gesture:
rock
paper
scissor
act play() -> Game:
act select(frm Gesture player1)
act select(frm Gesture player2)
if player2_wins(player1, player2):
print("you win")
else:
print("you lose")
fun main() -> Int:
let state = play()
state.select(Gesture::rock)
let copy = state
copy.select(Gesture::rock) # you lose
copy = state
copy.select(Gesture::scissor) # you lose
copy = state
copy.select(Gesture::paper) # you win
Because Rulebook supports serialization, you can copy coroutine states, explore different branches, and test all valid user interactions from any point. This makes techniques like automated testing, state-space exploration, and replay debugging straightforward and effective.
Inspectable
Sometimes the framework needs access to internal coroutine state—such as a variable a user selected earlier—in order to render UI elements, track progress, or drive decision-making.
Revisiting the rock-paper-scissors example, suppose you want to read which move player 1 selected without executing the full game logic:
enum Gesture:
rock
paper
scissor
act play() -> Game:
act select(frm Gesture player1)
act select(frm Gesture player2)
if player2_wins(player1, player2):
print("you win")
else:
print("you lose")
fun main() -> Int:
let state = play()
state.select(Gesture::rock)
print(state.player1) # rock
In Rulebook, any variable marked with the frm
keyword is automatically accessible from outside the coroutine. This allows you to treat action functions as lightweight stateful objects—like classes. If a concept is naturally procedural, you can model it as a coroutine. If it’s naturally structural, you can define it as a class. Rulebook supports both approaches seamlessly.
SPIN Function Implications
When used in an interpreted language or in combination with Rulebook’s action statement classes, SPIN functions unlock advanced techniques for managing complex interactive systems.
In addition to cleanly separating UI framework concerns from application logic, SPIN functions allow you to store, load, print, replay, and modify both the program’s state and its execution trace.
Here’s an example: this interactive program selects random actions, applies them to a state, stores the resulting actions in a trace, and later replays that trace on a new instance of the program.
@classes
act installer() -> Installer:
show_path_screen_prompt()
act select_location(frm String path) { !file_exists(path) }
show_experimental_content_prompt()
act install_experimental_content(Bool do_it)
if do_it:
install_with_extra(path)
else:
install(path)
fun main() -> Int:
let trace : Vector<AnyInstallerAction>
let state = installer()
while !state.is_done():
let action = select_random_valid_action(state)
apply(state, action)
trace.append(state)
state = installer()
for action in trace:
apply(state, action)
return 0
If select_random_valid_action
chooses valid actions, the trace might look like this:
{ InstallerSelectLocation{ path: "some_valid_path" }, InstallerInstallExperimentalContent{ do_it: true } }
This technique is fundamental for:
Fuzz testing
Reinforcement learning
Behavioral verification
By recording and replaying action sequences, you can rigorously test application logic independently of the surrounding infrastructure.
Action Statement Classes
In interactive systems, you may want to delay the execution of an action—such as processing a user click after a few frames. Rulebook makes this possible using the @classes
attribute on action functions.
Here’s an example. A rendering loop polls GUI events, converts them into actions, queues them, and applies them with a delay:
@classes
act installer() -> Installer:
show_path_screen_prompt()
act select_location(frm String path) { !file_exists(path) }
show_experimental_content_prompt()
act install_experimental_content(Bool do_it)
if do_it:
install_with_extra(path)
else:
install(path)
fun main() -> Int:
let state : installer()
let delayed_events : DelayedEventQueue
let ui : GUI
while true:
render_frame()
let events = poll_user_events()
# Queue an event to be processed later
if event.clicked_some_button:
let action : InstallerSelectLocation
action.path = ui.path_field.text
delayed_events.append(action)
if event.clicked_on_checkbox:
let action : InstallerInstallExperimentalContent
action.do_it = ui.checkbox.is_set
delayed_events.append(action)
# Apply events after a delay
for event in delayed_events.get_events_to_apply():
if can apply(event, state):
apply(event, state)
When you annotate an action function with @classes
, Rulebook generates a class for each action statement. These classes only contain the relevant arguments. Additionally, a type-safe union—AnyInstallerAction
—is created to represent all possible actions.
In the example above, the generated classes would look like:
cls InstallerSelectLocation:
String location
cls InstallerInstallExperimentalContent:
Bool do_it
using AnyInstallerAction = InstallerSelectLocation | InstallerInstallExperimentalContent
These classes can be stored, serialized, printed, copied, and—most importantly—applied using the built-in apply
function.
By treating actions as first-class values, Rulebook allows you to schedule and manage user interactions without coupling your application logic to the GUI framework—encouraging cleaner, more modular architectures.
Composing Actions
Complex interactive subsystems—the kind Rulebook is built to handle—often benefit from being broken down into smaller, reusable parts.
Rulebook enables this with a mechanism for composing action functions. Here’s an example of how to structure a compound interaction like rolling two dice, with the added ability to reroll based on the outcome:
act roll_2_dice(Int num_faces) -> RollDice:
act roll(frm Int first) {first > 0, first <= num_faces}
act roll(frm Int second) {second > 0, second <= num_faces}
act rerollable_roll() -> RerollableDices:
subaction* roll = roll_2_dice(6)
if roll.first + roll.second == 2:
roll = roll_2_dice(6)
subaction* roll
fun main() -> Int:
let sequence = rerollable_roll()
sequence.roll(1)
sequence.roll(1)
sequence.roll(3)
sequence.roll(4)
print(sequence.roll.first) # 3
print(sequence.roll.second) # 4
The keyword subaction*
exposes the inner action function’s interface to the outside world—until it completes. This lets you structure your logic as nested interactive sequences, where each subsequence can be reused, composed, and tested in isolation.
Read more about subaction*
here.
Use Cases
Now that we’ve explored how action functions work, let’s look at real-world applications.
Automatic Testing
Rulebook (RLC) includes a built-in fuzzer that can generate and apply random actions to your interactive programs—out of the box or customized for your needs.
Curious about fuzzing? Here’s a brief intro.
Below is a simple example: an interactive sequence that collects user data. However, there’s a bug—the program doesn’t guard against invalid age values (e.g. zero or negative numbers):
import action
@classes
act ask_user_data() -> AskUserData:
act insert_nationality(frm Int nationality_id)
act insert_age(frm Int age)
if age > 18:
return # nothing else to do
act insert_parent_nationality(frm Int parent_nation_id)
if age < 0:
assert(false, "age cannot be negative")
fun fuzz(Vector<Byte> input):
if input.size() == 0:
return
let state = ask_user_data()
let action : AnyAskUserDataAction
let trace = parse_actions(action, input)
for action in trace:
if can apply(action, state):
apply(action, state)
print(action)
You can compile and run this test like so:
# On Linux
rlc file.rl -o executable --fuzzer
./executable # Automatically crashes if invalid input is found
./executable crashing_trace.txt
# Output:
# ./file.rl:12:9 error: age cannot be negative
With minimal effort, you’ve created a property-based test that finds bugs by driving your interactive sequence with random—but valid—input.
In this case, the fix is simple: update the action statement with a guard condition:
act insert_age(frm Int age) { age >= 0 }
Why This Matters
Fuzzing is useful not only for ensuring robustness, but also for rapid prototyping. If you’re iterating quickly on an interactive flow you might throw away tomorrow, don’t spend time writing manual tests—just run the fuzzer and confirm it doesn’t crash.
That said, most of a fuzzer’s time is spent generating and discarding invalid inputs. In production, you’ll often filter actions using can apply
, so testing those rejections isn’t always necessary.
If your system allows for fully enumerating all valid actions—see Finite Interactive Programs—you can write fuzzers that are far more efficient and targeted.
And depending on the design of your application, additional optimizations may be possible.
Finite Interactive Programs
Finite interactive programs are systems in which the user can take only a limited number of distinct actions, even if the total number of possible combinations is large.
Examples:
Chess: While games can, in theory, go on indefinitely, at any given moment each player can only choose from a finite number of moves (limited by the number of pieces and legal board positions).
Mechanical vending machine: The user can insert a fixed set of coins and choose from a predefined set of products.
Because of their finiteness, these systems are excellent candidates for classic algorithms such as state space search.
Example: Exhaustive State Testing
Here’s a Rulebook example that explores all valid states of a simple tic-tac-toe game using enumerate
, and verifies that the program never crashes, regardless of the user’s input.
@classes
act play() -> Game:
frm board : Board
while !board.full():
act mark(BInt<0, 3> x, BInt<0, 3> y) {
board.get(x.value, y.value) == 0
}
board.set(x.value, y.value, board.current_player())
if board.three_in_a_line_player(board.current_player()):
return
board.next_turn()
fun main() -> Int:
let frontier : Vector<Game>
let any_action : AnyGameAction
let actions = enumerate(any_action) # contains 9 elements
frontier.append(play())
while !frontier.empty():
let state = frontier.pop()
for action in actions:
if can apply(action, state):
frontier.append(state)
apply(action, frontier.back())
return 0
The core idea here is the use of BInt<0, 3>
, which restricts inputs to integers between 0 and 2 (inclusive of 0, exclusive of 3). This constraint ensures that all mark
actions are within valid board coordinates. In fact, this program formally guarantees that play()
will never crash under any valid sequence of moves.
While this technique doesn’t scale to large programs due to the exponential growth of possible states, Rulebook’s composable architecture allows you to isolate and test individual interactive sequences—even if the entire system can’t be exhaustively verified.
Reinforcement Learning
If you install Rulebook via the Python package rl_language
, you gain access to built-in support for reinforcement learning.
Previously, we saw how to:
However, these methods focus on correctness—determining whether a program crashes or not. What if you’re interested in performance, or how a particular metric evolves over time?
This is where reinforcement learning shines.
Example: “Catch” — A Simple RL Environment
The following program implements Catch, a classic test environment in reinforcement learning. In this game, the player moves left or right to catch a falling ball. The score is 1 if the ball is caught; 0 otherwise.
The program is more detailed than previous examples because it includes:
Number of players
Current player logic
Game state and scoring metrics
If the implementation is correct, a trained agent should eventually achieve a near-perfect average score.
import serialization.print
import range
import collections.vector
import machine_learning
import action
# Constants for the game
const NUM_ROWS = 11
const NUM_COLUMS = 5
enum Direction:
Left
None
Right
fun equal(Direction other) -> Bool:
return self.value == other.value
fun not_equal(Direction other) -> Bool:
return !(self.value == other.value)
using Column = BInt<0, NUM_COLUMS>
using Row = BInt<0, NUM_ROWS>
# The main Catch game
@classes
act play() -> Game:
frm ball_row : Row
frm ball_col : Column
frm paddle_col : Column
# Initialization - chance player selects starting ball column
act set_start_location(Column col)
ball_col.value = col.value
paddle_col = NUM_COLUMS / 2
# Game loop - player makes moves until ball reaches bottom
while ball_row != NUM_ROWS - 1:
act move(Direction direction)
ball_row.value = ball_row.value + 1
let actual_direction = direction.value - 1 # Convert to -1, 0, 1
paddle_col = paddle_col.value + actual_direction
# Cell states
enum CellState:
Empty
Paddle
Ball
fun equal(CellState other) -> Bool:
return self.value == other.value
fun not_equal(CellState other) -> Bool:
return !(self.value == other.value)
# Get cell state at specific row and column
fun cell_at(Game game, Int row, Int col) -> CellState:
if row == NUM_ROWS - 1 and col == game.paddle_col.value:
return CellState::Paddle
else if row == game.ball_row.value and col == game.ball_col.value:
return CellState::Ball
return CellState::Empty
# Function for machine learning components to display the game state
fun pretty_print(Game game):
# Generate string representation of the board
let result = ""s
for row in range(NUM_ROWS):
for col in range(NUM_COLUMS):
if cell_at(game, row, col) == CellState::Empty:
result = result + "."s
else if cell_at(game, row, col) == CellState::Paddle:
result = result + "x"s
else:
result = result + "o"s
result = result + "\n"s
print(result)
# Return current player or special value if game is done
fun get_current_player(Game g) -> Int:
if g.is_done():
return -4 # Terminal state
let column : Column
if can g.set_start_location(column):
return -1
return 0 # Player 0 (the only player)
# Return score for ML training
fun score(Game g, Int player_id) -> Float:
if !g.is_done():
return 0.0
if g.paddle_col.value == g.ball_col.value:
return 1.0
else:
return 0.0
# Return number of players (always 1 for this game)
fun get_num_players() -> Int:
return 1
The program is simply run with
rlc-learn file.rl -o network #ctrl+c to stop it after a while
rlc-play file.rk network # to see a game
The user never had to specify anything related to reinforcement learning. They simply defined the environment’s rules—and everything else was inferred automatically.
The system learns to maximize the score based on how the game works. That score can be visualized later, making this approach valuable in two ways:
To determine the maximum achievable performance of a program.
To verify that the program behaves as expected—and that internal variables don’t take on surprising or incorrect values.
Self-Configuring UIs
One of Rulebook’s unique strengths is that it exposes all action types available in an action function. This allows UI code to:
Inspect the current program state.
Determine which actions are currently valid.
Adapt dynamically to changes in application logic.
Example: UI for Tic-Tac-Toe
Imagine you’re building a UI for a tic-tac-toe game. You’ve created 9 square components, one for each cell. Each square should:
Flash when it can be clicked.
Show its current content (empty, X, or O).
Work correctly even if game rules change.
Using action classes, you can achieve all of that with minimal coupling:
@classes
act play() -> Game:
frm board : Board
while !board.full():
act mark(BInt<0, 3> x, BInt<0, 3> y) {
board.get(x.value, y.value) == 0
}
board.set(x.value, y.value, board.current_player())
if board.three_in_a_line_player(board.current_player()):
return
board.next_turn()
cls UISquare:
Int x
Int y
def update(Game state):
let action = GameMark
action.x = x
action.y = y
if can apply(action, state):
render_quad(glow, x, y)
else if state.board.slots[x][y] == 0:
render_quad(grey, x, y)
else if state.board.slots[x][y] == 1:
render_quad(blue, x, y)
else:
render_quad(red, x, y)
fun main() -> Int:
let state = play()
let ui_quads = make_quads()
while true:
for quad in ui_quads:
quad.update(state)
poll_and_apply_events(state)
return 0
This UI is entirely driven by:
The
board
data structure.The
mark
action and itscan
condition.
As long as these remain consistent, the UI will continue to function—even if the game rules or logic change behind the scenes. This makes Rulebook a natural fit for self-configuring, resilient user interfaces.
Compatibility
Rulebook offers bidirectional interoperability with:
C
C++
Python
C#
Godot
Example: C# Interop
Here’s how a C# program might interact with a Rulebook coroutine:
act play() -> Game:
frm local_var = 0
act pick(Int x)
local_var = x
class Tester {
public void test() {
Game pair = RLC.play();
pair.pick(3);
return (int)(pair.local_var - 3);
}
}
To take full advantage of Rulebook’s features, it’s recommended to use only copyable and default-constructible types inside Rulebook programs. If that’s not possible, you can use the ctx
feature to inject context objects for handling non-copyable types outside the coroutine.
All advanced features in Rulebook are opt-in. If you don’t use them, you pay no performance or complexity cost. For example:
You can apply enumerate only to testable UI components, even if the full system is not finite.
You can use reinforcement learning selectively, or avoid it altogether.
You can rely on trace replay only in subsystems where time-invariance holds.
Rulebook is designed to scale gracefully based on the features you choose to use.
More on interoperability here.
Remote Execution
Since Rulebook supports serialization for action statement classes and is interoperable across multiple languages, remote execution can be implemented at the action trace level.
That means:
You can run identical programs in two separate processes (even across languages).
As one instance executes actions, the trace can be sent to and replayed by the other.
This enables powerful distributed testing and simulation scenarios, and it’s already used in the 4Hammer project. It’s a natural extension of Rulebook’s SPIN function architecture.
Tooling
Rulebook includes a growing suite of development tools:
Autocomplete for Visual Studio Code and any editor with LSP (Language Server Protocol) support.
Language bindings with autocomplete for each supported language (e.g., C#, Python).
Basic GDB support, though debugging is rarely needed due to the power of:
Trace replay
Fuzz testing
Serialization
The 4Hammer example was written without a debugger, relying solely on Rulebook’s core tooling. That said, if debugging support is important to you, let the team know—it’s an area they’re actively improving.
Performance
Rulebook is designed for near-native performance, without sacrificing flexibility:
Action functions are stack-allocated coroutines.
Each one uses a single integer to track its resume point.
Structs match the C ABI exactly.
Unions are like C unions, with one added integer to indicate the active variant.
All arguments are passed by reference, and return values are by value (unless marked
ref
).
The only performance trade-off comes from serialization:
Rulebook uses integer indices instead of raw pointers, so all structures can be stored and restored safely.
If absolute speed is needed, you can manually manage pointers and specify how they’re serialized/deserialized.
Importantly, any other language offering equivalent serialization guarantees would face similar trade-offs. Rulebook just makes the implications explicit.
We benchmark performance against C++ in the official language paper.
ToDo: Add link to the paper after de-anonymization.