Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

lil Book

A small programming language you can interpret or compile to a standalone binary.

Getting Started

lil is a simple, fast programming language that can run scripts directly or compile them to standalone C binaries. If you know Python, you’ll find the syntax familiar but much simpler.

What Makes lil Different from Python

  • No indentation rules. Use {} for blocks, just like C.
  • No colons after if, for, while, or function definitions.
  • No self parameter. No __init__. No classes at all.
  • No return keyword. Functions return the value of their last expression.
  • No import statement. Use include to load other script files.
  • Variables are dynamically typed (like Python), but the compiler infers types for C codegen.

What is lil?

lil is a small dynamically-typed programming language. It supports two modes of execution:

  • Interpreted mode: the lil binary reads your script, parses it, and runs it immediately through its bytecode VM.
  • Compiled mode: the lil binary generates a C file from your script, then compiles it with gcc into a standalone executable.

The syntax is minimal. There are no classes, no modules, no type annotations. Just variables, control flow, functions, and a few built-in libraries.

If you already know Python or Lua, you will pick up lil in a few minutes.

Installation

Requirements

  • A C99 compiler (gcc, clang, or compatible)
  • git
  • make (optional)

Building from source

Python devs are used to pip install. lil works differently: you clone the repo and compile it yourself:

git clone https://github.com/XLRC888/lil.git
cd lil
./install.sh

This compiles lil.c and copies the binary to ~/.local/bin/lil. Make sure that directory is on your PATH.

Check your PATH to confirm ~/.local/bin is listed:

echo $PATH    # look for /home/yourname/.local/bin

If you prefer to build in place:

make
./lil

After rebuilding with make, always run make install to keep the system binary up to date:

make
make install

For GTK support:

make gtk
make install-gtk

Verifying

Run lil --help to confirm everything works:

$ lil --help
usage: lil [options] [file.lil]
  no args      start REPL
  file.lil     execute file
  -c file.lil  compile to standalone binary
  -o output    output filename (default: a.out)

Hello, World

Create a file called hello.lil:

write("hello, world")

Run it:

lil hello.lil

In Python you write print("hello") with parentheses. In lil, write("hello") is a function call with parentheses.

The write() function outputs text to stdout and appends a newline:

write(42)
write("hello")

You can write multiple values by separating them with commas:

write("the answer is", 42)

This prints: the answer is 42

Write with a variable:

name = "alice"
write("hello", name)

Numbers and Strings

Numbers

All numbers are floating point internally (C double). You can use them like integers:

x = 42
y = 3.14
z = -7

Strings

String literals use double quotes:

greeting = "hello"

You can concatenate strings with +:

a = "hello "
b = "world"
c = a + b
write(c)

If you add a string to a number, the number is converted to a string first:

x = "score: " + 42
write(x)

String length

Get the length of a string with len@string:

name = "alice"
len = len@string("hello")
write(name, "has", len, "characters")

Math on numbers

You can write math expressions directly:

result = 3.14 * 2
write(result)

r = 0.5
write(r)

Type conversion

Use stringify and intify to convert between types explicitly.

stringify converts a variable to its string representation:

x = 42
stringify x

intify converts a variable to a number. If the string is not numeric, it produces an error:

x = "42"
intify x
x = x + 5
write(x)

x = "hello"
intify x

intify can also format a string as binary, hex, or octal byte representation:

x = "hello"
intify x binary
intify x hex
intify x octal

toggle toggles a variable between number and string. If its a number, it becomes a string. If its a string, it becomes a number:

x = 42
stringify x     : now "42"
toggle x        : back to 42
toggle x        : back to "42"
toggle x        : back to 42

Variables

Variables hold values. You assign them with =:

x = 10
name = "alice"

Variable names must start with a letter and can contain letters, digits, and underscores.

Variables do not need to be declared before use. The first time you assign to a name, the variable is created. Use %name to explicitly declare a variable with value 0:

%counter
write(counter)     : 0
counter = 10
write(counter)     : 10

If you use a variable that has not been assigned yet, its value defaults to 0:

write(z)

In Python, an undefined variable raises NameError. In lil, you get 0. This is useful for counters and accumulators that start at zero implicitly.

Counting up works naturally:

count = 0
count = count + 1
count = count + 1
write(count)

You can reassign variables to values of any type:

x = 42
x = "now im a string"
x = 99

Assignment always goes variable = expression:

y = x + 50

Assign a number to a variable:

n = 10
write("number:", n)

Store the result of a math expression:

area = 3.14 * 5 * 5
write("area is", area)

Forcing variable types

force locks a variable completely. Once forced, you cannot change it with stringify, intify, toggle, or regular assignment:

x = 42
force x
x = 30        : ERROR: cannot assign to a forced variable
stringify x   : ERROR: cannot stringify a forced variable
unforce x     : unlock it
x = 30        : works again

Combined assignment and force:

force x = 42
unforce x = 56

unforce unlocks the variable so it can be changed again.

force with read() reads input and forces the variable to a specific type:

age = read("Enter your age: ")
intify age
force age
write("next year you will be", age + 1)

For string input with force:

name = read("Enter your name: ")
force name
write("hello", name)

Input validation without force

Use read() with intify to validate the input type without forcing the variable:

x = read("Enter a number: ")
intify x
x = "now im a string"     : allowed (not forced)
y = read("Enter text: ")
y = 42                     : allowed (not forced)

intify errors if the value is not a valid number. read() always returns a string.

Live Bindings

A live binding links one variable to another so that reads of the bound variable always reflect the current value of its source:

y = 1
live x = y
y = 42
write(x)     : prints 42

After live x = y, every read of x follows the link back to y and returns whatever y currently holds. You do not need to reassign x when y changes.

Assigning directly to x overwrites the binding and clears the live link. For a one-time copy, use plain assignment (x = y).

Changing the undefined default

By default, reading an undefined variable returns 0. Use ?default = value to change this:

?default = "Undefined"
write(someVar)     : prints "Undefined"

?default = 0
write(anotherVar)  : prints 0

The name after ? is descriptive only and is ignored by the runtime. Only one value is stored globally. Once set, all undefined variables will return that value until changed.

Comments

: starts a single-line comment. Everything after : on that line is ignored:

: this is a comment
x = 42

:: starts a block comment. Everything between :: and the next :: is ignored:

:: this is a
block comment ::
x = 10

Block comments can span multiple lines:

::
this is
a multi-line
comment
::

# is for function definitions, not comments: #add(a, b) { a + b }.

// is integer division, not a comment: 10 // 3 = 3.

Operators

Arithmetic

a = 10 + 3
b = 10 - 3
c = 10 * 3
d = 10 / 3
e = 10 % 3

Integer division:

x = 10 // 3    : 3

Modulo with negative numbers follows the sign of the dividend:

write(-10 % 3)
write(10 % -3)

Unary minus

x = -5
y = -(x + 3)

Logical operators

lil uses and, or, not like Python, but also supports &&, ||, ! as aliases:

if a > 0 and b > 0 {
    write("both positive")
}
if a > 0 || b > 0 {
    write("at least one is positive")
}

Short-circuit &&

&& short-circuits: the right side is only evaluated if the left side is truthy. It returns the actual value, not 0/1:

x = 0
result = x && 5    : returns 0 (x is falsy, 5 never evaluated)
y = "hello"
result2 = y && 42   : returns 42 (y is truthy)

Useful in while conditions:

while i < 10 && arr[i] != "" {
    i = i + 1
}

Statement separator ;

; separates multiple statements on one line. It has the lowest precedence:

a = 5 ; b = 10 ; write(a + b)

Special syntax

# defines a function. #add(a, b) { a + b } creates a function named add. #(x) { x * 2 } creates an anonymous function (closure) without a name.

@ makes a library call. randint@math(1, 10) calls randint from the math library.

% declares a variable with value 0. %x is the same as x = 0.

Operator precedence

From lowest to highest:

  1. ;
  2. or
  3. and
  4. not
  5. ==, !=, <, >, <=, >=
  6. +, -
  7. *, /, %, //
  8. unary -, unary not
  9. parentheses ()

Use parentheses for clarity:

result = (1 + 2) * (3 + 4)

Precedence matters in real calculations:

x = 10 + 5 * 2
write(x)
y = (10 + 5) * 2
write(y)

Direct arithmetic

lil evaluates math expressions directly:

result = (10 + 5) * 2
write(result)

Comparison and Logic

Comparisons

Comparisons return 1 (true) or 0 (false). The operators are the same as Python:

x = 5 == 5    : 1 (true)
y = 5 != 5    : 0 (false)
z = 5 < 10    : 1
w = 5 >= 10   : 0

Full list: ==, !=, <, >, <=, >=

You can compare strings lexicographically, same as Python:

write("apple" < "banana")   : 1
write("abc" == "abc")        : 1

Complex conditions

Combine comparisons with and, or, not:

x = 7
if x > 0 and x < 10 {
  write("in range")
}

Store the result of a complex condition:

in_range = x > 0 and x < 10
if in_range {
  write("x is between 1 and 9")
}

Using not for negation

logged_in = 0
if not logged_in {
  write("please log in")
}

Comparison with variables

Compare a variable to a value:

len = 10
if len > 5 {
  write("long name")
}

Store and use multiple comparisons:

len = 10
if len > 2 and len < 10 {
  write("name length is reasonable")
}

Logical operators

a = 1 and 0    : 0 (false)
b = 1 or 0     : 1 (true)
c = not 1      : 0 (false)

and and or evaluate both operands unconditionally. && short-circuits (right side is only evaluated if the left side is truthy) and returns the actual value, not 0/1:

x = 0
result = x && 5    : returns 0 (x is falsy)
y = "hello"
result2 = y && 42   : returns 42 (y is truthy)

Truthiness

For conditions, a value is considered true if it is a non-zero number or a non-empty string:

if "hello" { write("yes") }   : prints "yes" (non-empty string is truthy)
if 0 { write("no") }          : prints nothing (zero is falsy)
if "" { write("no") }         : prints nothing (empty string is falsy)

If and Else

Basic if

x = 10
if x > 5 {
  write("big")
}

If/else

x = 2
if x > 5 {
  write("big")
} else {
  write("small")
}

If/orif/else

x = 5
if x > 10 {
  write("big")
} orif x > 3 {
  write("medium")
} else {
  write("small")
}

You can chain as many orif blocks as you need. elif still works as an alias.

Inline conditions

You can put small blocks on one line:

if x > 5 { write("big") } else { write("small") }

The Has Operator

The has operator checks if a variable exists or if a string contains a substring. It can only be used inside if conditions.

Variable existence

if has x {
  write("x exists")
}

This checks whether the variable x has been assigned. If the variable was never assigned, this is false.

A practical use: check if data was read successfully:

data = read@file("data.txt")
if has data {
  write("read succeeded")
}

read@file returns empty string if the file doesn’t exist. has data tells you if data is non-empty.

text = "hello world"
if text has "world" {
  write("found")
}

Check if a URL contains a protocol:

url = "https://example.com"
if url has "https" {
  write("secure connection")
}

Multiple has conditions combined

You can chain not with has:

if has result and result has "error" {
  write("got an error response")
}

If result was never set, the first has is false and the substring check is never evaluated. This is a safe way to check both existence and content.

Flags

flagmeaning
nocasecase insensitive search
anywheresearch anywhere in the string (default)
wordmatch whole words only
if text has nocase "WORLD" {
  write("found case insensitive")
}

if text has word "world" {
  write("found as whole word")
}

You can combine flags:

if text has nocase anywhere "WORLD" {
  write("found")
}

Has vs regular comparison

has checks existence, not value. Use == to check a value:

x = 0
if has x { write("x exists") }   : prints (x was assigned)
if x == 0 { write("x is zero") } : also prints

y = ""
if has y { write("y exists") }   : prints (y was assigned, even empty)
if y == "" { write("y is empty") } : also prints

A variable can exist but hold a falsy value. Use has when you care whether it was assigned at all, not what it holds.

Functions

lil has two kinds of functions: built-in library functions from the standard libraries, and user-defined functions you write yourself.

Built-in Library Functions

These are functions that come with lil, organized by library like math, string, file, sys, and date. You call them with func(args)@lib syntax:

randint@math(1, 6)

The function name comes first, then @, then the library name, then arguments in parentheses.

name = "alice"
upper@string(name)
write(name)

See the Built-in Libraries page for the full list of available functions.

User-defined Functions

When you need custom logic, define your own functions with # syntax:

#add(a, b) {
  a + b
}

There’s no def, no colon, and no return keyword. The function body is a block in braces, and the last expression’s value is automatically returned.

result = add(3, 4)
write(result)

Functions can also be created without a name (anonymous functions/closures) for inline use:

double = #(x) { x * 2 }
write(double(5))

See User-defined Functions and Advanced Functions for details.

Choosing Which to Use

Library functions handle built-in operations: math, strings, files, dates, system commands. User-defined functions let you organize your own code, avoid repetition, and build reusable abstractions. You’ll typically use both together.

Built-in Libraries

Libraries must be imported with include before use:

include math
include date
include string
include file
include sys
include gtk
include list

Libraries can be disabled with drop:

include math
randint@math(1, 10)
drop math
randint@math(1, 10)    : error: library not imported

If the library wasn’t included, drop prints a warning and continues:

drop math          : warning: library 'math' was not included

Libraries are called with func(args)@lib syntax:

<function>@<library>(<arguments...>)

Arguments are comma-separated in parentheses. Bare variable names are auto-dereferenced. Use double quotes for literal strings.

string library

lower@string(s)
upper@string(s)
reverse@string(s)
len@string(s)
repeat@string(s, 3)
substr@string(s, 1, 3)
replace@string(s, "old", "new")
split@string(s, ",")
join@string(list, ",")
contains@string(s, "sub")
find@string(s, "sub")
ord@string(s)
chr@string(65)
isdigit@string(s)
isalpha@string(s)
isalnum@string(s)
isspace@string(s)

split returns a list of substrings:

include string
parts = split@string("a,b,c", ",")
write(parts)

join concatenates list elements with a delimiter:

include string
result = join@string(parts, "-")
write(result)

contains returns 1 if the string contains the substring, 0 otherwise:

include string
x = contains@string("hello world", "world")
write(x)

find returns the index of the first occurrence, or -1 if not found:

include string
idx = find@string("hello", "ell")
write(idx)

ord returns the ASCII code of the first character:

include string
code = ord@string("A")
write(code)

chr returns a single-character string from an ASCII code:

include string
c = chr@string(65)
write(c)

Character classifiers check if a string consists entirely of the given character type:

include string
write(isdigit@string("123"))
write(isalpha@string("abc"))
write(isalnum@string("a1"))
write(isspace@string("  "))
write(isdigit@string("12a"))

Bare variable names are auto-referenced, no $ needed:

name = "alice"
upper@string(name)
write(name)

Assign the result to a variable:

name = "alice"
len = len@string(name)
write(len)

Method chaining works on values:

name = "HELLO"
lower_name = name.lower()@string
write(lower_name)

clean = "  HELLO  ".replace(" ", "")@string.lower()@string
write(clean)

math library

calc@math("2 + 3 * 4")
random@math()
randint@math(1, 10)
factors@math(12)
fib@math(10)
isprime@math(7)
sleep@math(0.5)
hasops@math("2+3")
choice@math("a", "b", "c")

Assign results to variables:

roll = randint@math(1, 6)
write("you rolled", roll)
n = random@math()
write(n)
prime = isprime@math(17)
write(prime)
dice = choice@math("fire", "ice", "lightning")
write(dice)

file library

content = read@file("path")
write@file("path", "content")
append@file("path", "content")
delete@file("path")
exists = exists@file("path")
listing = list@file("path")

Most file functions return values you’ll want to capture:

data = read@file("config.txt")
write(data)
ok = delete@file("temp.tmp")
if ok == 1 {
  write("deleted")
}
files = list@file("/home/user")
write(files)
cfg = read@file("settings.cfg")
if cfg == "" {
  write("no config found")
}

sys library

output = cmd@sys("command")
value = env@sys("VARNAME")

Assign results to check environment variables or capture command output:

home = env@sys("HOME")
write("home directory:", home)
result = cmd@sys("ls -la")
write(result)
shell = env@sys("SHELL")
write("your shell is", shell)

Debugging

include sys
log@sys("debug message")

log@sys prints to stderr. last_error@sys returns the last error from a failed FFI call. clear_error@sys resets it.

list library

x = new@list
push@list(x, 1)
push@list(x, 2)
push@list(x, 3)
val = pop@list(x)
write(x[0])
write(len@list(x))

Create a list, add elements, remove and access them. Read elements with bracket syntax x[i]:

include list
items = new@list
push@list(items, "apple")
push@list(items, "banana")
push@list(items, "cherry")
write(items[1])

Push returns 0, pop returns the removed element. len@list returns the count.

map/filter/reduce

Higher-order functions that take a user-defined function by name:

include list

#double(x) { x * 2 }

nums = new@list
push@list(nums, 1)
push@list(nums, 2)
push@list(nums, 3)

doubled = map@list(nums, double)
write(doubled)
#isbig(x) { if x > 5 { 1 } else { 0 } }

filtered = filter@list(nums, isbig)
write(filtered)
#add(a, b) { a + b }

total = reduce@list(nums, add, 0)
write(total)

map@list returns a new list with the function applied to each element. filter@list keeps elements where the function returns truthy. reduce@list combines all elements with a two-parameter function and an initial value.

dict library

d = new@dict
set@dict(d, "name", "alice")
set@dict(d, "age", 30)
write(get@dict(d, "name"))
write(contains@dict(d, "age"))
write(len@dict(d))
write(keys@dict(d))

Dicts store key-value pairs. get@dict returns the value or 0 if missing.

remove@dict(d, "age")
clear@dict(d)

date library

write(minimal@date)
write(standart@date)
write(standartplus@date)
write(full@date)
write(full@date, noseconds)
write(full@date, noday)
write(fullplus@date)
format@date(eu)
format@date(us)
write(format@date)

Dates are commonly assigned to variables for later use:

today = standart@date
write("date:", today)
now = full@date
write(now)
short = minimal@date
write(short)

Most date functions return the date string. format@date returns the current mode (“EU”/“US”), and setting the format returns empty.

Variable extraction (get)

Extract variable values from other .lil files at runtime:

get "TOKEN" from /path/to/file.lil
get "TOKEN" from file.lil              : relative path
get "TOKEN" from /path/to/file         : .lil added automatically

Selective extraction with assignment index:

get "x"(2) from /tmp/data.lil          : 2nd-to-last assignment of x

Renaming on import:

get "TOKEN"="api_key" from auth.lil     : saves as api_key instead of TOKEN

Multiple variables in one call:

get "x", "y", "name" from data.lil

The target file is run in a sandboxed interpreter. Its variable state is isolated from the current script.

Templates

Template strings let you embed variable values inside a string using {varname} with backtick strings. In Python this would be an f-string like f"hello {name}". In lil it’s `hello {name}`:

name = "alice"
age = 30
write(`my name is {name} and i am {age} years old`)

This prints my name is alice and i am 30 years old.

Template vs Concatenation

Without templates:

greeting = "hello " + name + " you are " + age + " years old"

With templates:

greeting = `hello {name} you are {age} years old`

Templates are cleaner for anything more than a single concatenation.

Practical example

username = "bob"
score = 95
msg = `player {username} scored {score} points`
write(msg)

Mixing with library calls

now = minimal@date
write(`the time is {now}`)
rand = random@math()
write(`your lucky number is {rand}`)

Templates in conditions

name = read@file("name.txt")
if len@string(name) > 0 {
  write(`welcome, {name}`)
}

What happens with undefined variables

If you use a variable name that doesn’t exist in a template, it evaluates to 0 (for numbers) or “” (for strings):

write(`value is {undefined_var}`)   : prints "value is 0"

Control Flow

This chapter covers all the ways to control what your program does next: if/orif/else conditions, while loops, for loops, and the loop/break/continue keywords.

If/orif/else

lil’s if/orif/else works like Python but without colons or parentheses. In Python you write:

if x > 5:
    print("big")
elif x > 0:
    print("small")
else:
    print("zero or negative")

In lil the same logic looks like:

if x > 5 {
  write("big")
} orif x > 0 {
  write("small")
} else {
  write("zero or negative")
}

Conditions don’t need parentheses around them. Just write the expression directly after if or orif. The braces are required even for single-line bodies.

elif still works as an alias for orif.

You can store condition results in variables:

is_positive = x > 0
if is_positive {
  write("x is positive")
}

Since conditions return 1 (true) or 0 (false), you can use them in arithmetic:

count = (x > 10) + (y > 10) + (z > 10)
write("variables over 10:", count)

Loop types

lil has three kinds of loops covered on their own pages:

All three support break and continue. (stop is a kept alias of break.)

Short-circuit &&

&& evaluates the right side only if the left side is truthy:

while i < 10 && arr[i] != "" {
    i = i + 1
}

Statement separator ;

Multiple statements on one line with ;:

a = 5 ; b = 10 ; write(a + b)

While Loops

Basic while

A while loop runs as long as its condition is truthy:

i = 0
while i < 5 {
  write(i)
  i = i + 1
}

This prints 0 through 4.

Sum of numbers 1 to 100

Accumulate a result across loop iterations:

total = 0
i = 1
while i <= 100 {
  total = total + i
  i = i + 1
}
write(total)

This sums 1 + 2 + … + 100 and prints 5050.

Random loop condition

Using random@math to create randomness in a loop condition:

count = 0
total = 0
while random@math * 100 > 10 {
  count = count + 1
  total = total + random@math * 100
}
write("loop ran", count, "times, total=", total)

Each iteration has a 90% chance of continuing.

Infinite loop with break

Use loop for an unconditional infinite loop (prefer this over while 1):

i = 0
loop {
  if i > 5 { break }
  write(i)
  i = i + 1
}

The break keyword exits the loop immediately. It works in while, for, and loop blocks.

Short-circuit in while

Use && to combine conditions safely:

i = 0
while i < 10 && arr[i] != "" {
  write(arr[i])
  i = i + 1
}

The right side is only evaluated if the left side is truthy.

Nested loops

i = 1
while i <= 3 {
  j = 1
  while j <= 3 {
    write(i, j)
    j = j + 1
  }
  i = i + 1
}

For Loops

The for loop iterates over a range of numbers. In Python you’d write for i in range(1, 6):. In lil it’s for i = 1 to 5 {:

for i = 1 to 5 {
  write(i)
}

This prints 1 through 5. The loop variable (i) starts at the first value and increments by 1 each iteration until it exceeds the second value. The end value is inclusive: for i = 1 to 5 iterates 1, 2, 3, 4, 5.

Python’s range(1, 6) includes 1 but excludes 6. lil’s 1 to 5 includes both ends.

Using the loop variable in calculations

for i = 1 to 10 {
  write(i * i)
}

Prints 1, 4, 9, 16, 25, 36, 49, 64, 81, 100. The loop variable is just a regular variable you can use in any expression.

Store results in another variable:

squares = 0
for i = 1 to 10 {
  squares = squares + i * i
}
write(squares)

This sums the squares of 1 through 10.

Expressions for range bounds

You can use expressions for the start and end values:

start = 2
end = 10
for i = start to end {
  write(i)
}

No counting down

lil’s for only counts up. To count down, use a while loop:

i = 10
while i >= 1 {
  write(i)
  i = i - 1
}

Nested for loops: multiplication table

for row = 1 to 5 {
  line = ""
  for col = 1 to 5 {
    line = line + " " + row * col
  }
  write(line)
}

Prints a 5x5 multiplication table. Each row builds up a string as the inner loop runs.

Break in for loops

for i = 1 to 100 {
  if i > 3 { break }
  write(i)
}

This prints 1, 2, 3.

Loop and Break

Loop

loop creates an infinite loop with no condition. Use break to exit:

loop {
  if condition { break }
}

This is equivalent to while 1 but reads more naturally for “do until done” patterns.

Break

break exits a loop. It works in loop, while, and for blocks.

i = 0
loop {
  write(i)
  i = i + 1
  if i > 5 { break }
}

stop is kept as an alias for backward compatibility, but break is the preferred keyword.

Practical example

A common pattern: run an event loop that breaks when a flag is set:

running = 1
loop {
  cmd = read()
  if cmd == "quit" { running = 0 }
  if cmd == "help" { write("commands: quit, help") }
  if not running { break }
}

The flag running starts as 1 (true). When the user types “quit”, the flag goes to 0 and the loop exits on the next iteration.

Repeating timer with sleep

Using sleep@math to run something every 2 seconds:

count = 0
loop {
  write("tick", count)
  count = count + 1
  sleep@math(2)
  if count >= 5 { break }
}

Sleep takes seconds. The loop ticks every 2 seconds for 5 iterations.

Continue

continue skips to the next iteration of a loop. It works in while, for, and loop blocks.

i = 0
while i < 10 {
  i = i + 1
  if i % 2 == 0 { continue }
  write(i)
}

This prints odd numbers 1, 3, 5, 7, 9. When i is even, continue jumps back to the condition check, skipping the print.

continue only skips the rest of the current iteration, it does not exit the loop. For that, use break.

User-defined Functions

Define a function with #name(params) syntax followed by a block in braces:

#greet() {
  write("hello")
}

greet()

Parameters and Return Values

Functions take parameters and return the last expression’s value. There’s no return keyword.

Python:

def add(a, b):
    return a + b

lil:

#add(a, b) {
  a + b
}

result = add(3, 4)
write(result)

Practical Examples

Convert Fahrenheit to Celsius:

#fahr_to_celsius(f) {
  (f - 32) * 5 / 9
}

temp = fahr_to_celsius(100)
write(temp)

Roll a die using a library function inside a user function:

#roll_dice() {
  randint@math(1, 6)
}

roll = roll_dice()
write("you rolled", roll)

Check if a number is even:

#is_even(n) {
  n % 2 == 0
}

x = is_even(10)
write(x)

Scope

Each function call gets its own scope. Parameters and local variables are isolated to that call and don’t leak:

#a(x) {
  x = x + 10
  x
}

#b(x) {
  x = x * 2
  x
}

write(a(5))    : 15
write(b(5))    : 10
write(a(100))  : 110

Two functions with the same parameter name don’t interfere with each other:

#a(x) {
  write(x)
}

#b(x) {
  a(x + 1)
  write(x)
}

b(100)

Variables created inside a function are local to that call and cleaned up when the function returns:

#outer() {
  x = 99
  inner()
  write(x)
}

#inner() {
  x = 42
}

outer()

Variables inside a function create new bindings in the function’s scope. They don’t modify outer-scope variables of the same name:

x = 10

#set_x(val) {
  x = val
}

set_x(42)
write(x)

Include

Functions defined in other files can be loaded with include:

include dice

roll = roll_dice()
write(roll)

This loads and runs dice.lil, making its functions available. For example, dice.lil might contain:

#roll_dice() {
  randint@math(1, 6)
}

Then your main script includes it and uses roll_dice like any local function.

Selective import: specify which functions to import by appending / and function names:

include dice.lil/roll_dice flip_coin

Only roll_dice and flip_coin are registered from dice.lil. Other functions are discarded. The file runs in an isolated variable scope.

Recursion

Functions can call themselves:

#fib(n) {
  if n <= 2 {
    1
  } else {
    fib(n - 1) + fib(n - 2)
  }
}

write(fib(10))

lil’s functions work in both interpreted and compiled (AOT) mode.

Anonymous Functions (Closures)

Functions without a name can be created inline with #(params) { body }:

double = #(x) { x * 2 }
write(double(5))

The real power is passing them to higher-order library functions:

include list

nums = new@list
push@list(nums, 1)
push@list(nums, 2)
push@list(nums, 3)

doubled = map@list(nums, #(x) { x * 2 })
write(doubled)

Anonymous functions see variables from the scope where they’re defined:

prefix = "> "
#add_prefix(s) { prefix + s }
write(add_prefix("hello"))

The anonymous function creates an internal name, registers in the function table, and returns the name as a string. map@list and filter@list resolve it automatically.

Functions with Parameters

Functions can take parameters and return values. This page covers practical patterns, library integration, and advanced behavior.

Defining a Function

Use #name(params) syntax followed by the function body in braces:

#add(a, b) {
  a + b
}

Calling a Function

result = add(3, 4)
write(result)

Practical Predicate Functions

Functions that return 0 or 1 as boolean results:

#is_even(n) {
  n % 2 == 0
}

#is_odd(n) {
  not is_even(n)
}

write(is_even(42))
write(is_odd(42))

Using Library Functions Inside User Functions

Built-in library functions work inside your user-defined functions:

#random_in_range(min, max) {
  randint@math(min, max)
}

r = random_in_range(5, 10)
write(r)

Bare variable names are auto-dereferenced in library calls:

#rand_name() {
  choice@math("alice", "bob", "charlie")
}

#greet() {
  name = rand_name()
  write("hello", name)
}

greet()
#slugify(s) {
  lower = lower@string(s)
  replace@string(lower, " ", "-")
}

slug = slugify("Hello World")
write(slug)

Return Values

The last expression in the function body is the return value:

#double(x) {
  x * 2
}

write(double(21))

If a function ends with an if expression, the taken branch’s last value is returned:

#sign(n) {
  if n > 0 {
    1
  } orif n < 0 {
    -1
  } else {
    0
  }
}

write(sign(-5))

If there’s no value-producing expression, it returns 0:

#noop() {
  write("no return")
}

x = noop()
write(x)

Variable Save/Restore

When you call a function, lil saves all current variables before running the function body and restores them after. Parameters and any variables changed inside stay contained:

x = "hello"

#test() {
  x = "world"
  write(x)
}

test()
write(x)

Parameters are always save/restored independently of global variables:

x = 100

#f(x) {
  x = x + 1
  write(x)
}

f(5)
write(x)

Recursion

#fib(n) {
  if n <= 2 {
    1
  } else {
    fib(n - 1) + fib(n - 2)
  }
}

write(fib(10))

Notes

Function calls work in both interpreted and compiled (AOT) mode. In compiled mode, each function becomes a standalone C function with its own type inference pass.

Input and Output

lil keeps I/O simpler than Python. No file objects, no context managers, no close() calls. Just read, write, and a few file library functions.

Write

The write() function outputs text:

write("hello world")

Write multiple values separated by commas:

name = "alice"
write("hello", name)
x = 42
write("the answer is", x)

Read

The read() function reads a line from stdin and returns it as a string:

name = read()
write("hello", name)

Show a prompt string:

name = read("enter your name: ")
write("hello", name)

In Python, you’d write name = input("enter your name: "). In lil, read() is a function that returns a string.

s = read("type something: ")
len = len@string(s)
write("you typed", len, "characters")

File I/O

lil uses the file library for file operations:

data = read@file("config.txt")

In Python that’s with open("config.txt") as f: data = f.read(). lil does it in one call.

text = read@file("notes.txt")
if text == "" {
  write("file is empty or missing")
}

Writing a file:

write@file("output.txt", "hello world")

You can write the result of an expression directly:

today = standart@date
write@file("date.txt", today)

Appending to a file:

append@file("log.txt", "new entry")

Check if a file exists:

if exists@file("data.txt") == 1 {
  data = read@file("data.txt")
  write(data)
}

List directory contents:

files = list@file("/home/user")
write(files)

See the Built-in Libraries page for the full file library reference.

Write (Output)

The write() function outputs values to stdout.

write("hello")
write(42)
write(3.14)

Multiple arguments are printed with spaces between them, followed by a newline:

write("the answer is", 42)

Write with no arguments prints a blank line:

write("")

Write the result of a library function directly:

write(full@date)

Write multiple results from different functions in one line:

write(minimal@date, random@math)

Write values alongside variables:

name = "bob"
score = 95
write("player", name, "scored", score)

Statement separator

Multiple statements can go on one line with ;:

write("a") ; write("b")

Comments

: starts a single-line comment. :: starts a block comment that ends with :::

: this is a comment
write("hello")

:: this is a
block comment ::
write("world")

// is integer division, not a comment: 10 // 3 = 3.

Read (Input)

The read() function reads a line from stdin and returns it as a string.

name = read()
write("hello", name)

You can provide a prompt string:

name = read("what is your name? ")
write("nice to meet you,", name)

If the user does not enter anything (EOF), the result is an empty string:

x = read("press enter to continue")
write("ok, continuing")

Force input as a number and do a calculation:

age = read("age: ")
intify age
write("next year youll be", age + 1)

Read a number input and use it in an expression directly after:

n = read("enter a number: ")
intify n
result = n * 2
write("doubled:", result)

Legacy syntax

The old input statement still works but read() is preferred:

input name
input "what is your name? " name

Error Handling

Try/Catch

Errors are caught with try/catch:

try {
  risky_stuff()
} catch {
  write("something went wrong")
}

If the code inside try triggers an error, execution jumps to catch immediately.

Catch Variable

You can optionally name the error to get a stack trace:

try {
  risky_stuff()
} catch e {
  write("error on line ", e.line)
  write("message: ", e.msg)
}

The error object has two fields:

  • e.line - the line number where the error occurred
  • e.msg - the error message string

Practical Example

Reading a file that might not exist:

data = ""
try {
  data = read@file("maybe.tmp")
} catch e {
  write("could not read file: ", e.msg)
}
write(data)

Division by Zero

try {
  x = 1 / 0
} catch e {
  write("math error: ", e.msg)
}

Type Conversion Errors

try {
  x = "hello"
  intify x
} catch e {
  write("conversion failed: ", e.msg)
}

What gets caught

Runtime errors, type errors, out-of-bounds access, division by zero, undefined variable access, and undefined function calls are all caught. Parse errors happen before the code runs and can’t be caught.

The script continues after catch finishes.

Modifiers

Modifiers use the ? prefix to change global behavior. Other features like get and include with function selection are covered here.

Modifiers

Get Statement

The get statement extracts variable values from an external file without leaking its variables into your scope:

get "x", "y" from data.lil

This runs data.lil in a sandbox, snapshots the values of x and y, then copies them into your script. The file’s own variables are discarded.

Indexed access: retrieve historical (previous) assignment values:

get "x"(1) from data.lil    : 1st previous value of x
get "x"(2) from data.lil    : 2nd previous value

Index 0 (or no index) returns the current value. Uses the assignment history tracking in the VM.

Rename on import: map the file’s variable to a different name:

get "x" = "myX", "y" = "myY" from data.lil

The file’s x goes into your script’s myX, and y goes into myY.

Include with Function Selection

The include statement can import specific functions from a file instead of registering everything:

include dice.lil/roll_dice flip_coin

Only roll_dice and flip_coin from dice.lil are registered. Other functions defined in the file are discarded. This is useful for utility files that define many helpers but you only need a few.

Undefined Default

By default, reading an undefined variable returns 0. Use ?default = value to change this:

?default = "hello"
write(x)       : prints "hello"

?default = 99
write(y)       : prints 99

The name after ? is descriptive only and is ignored by the runtime. Only one global default exists. Once set, every undefined variable will return that value until you change it again.

Type Annotations

lil supports gradual type annotations using the typed keyword. Variables can be annotated with a type, and the runtime will automatically coerce values or error on incompatible types.

Variable Annotations

typed int x = 42
typed str name = "hello"
typed float pi = 3.14
typed bool flag = 1

Automatic Coercion

The runtime converts values when possible:

typed int x = "100"    : x is 100 (number)
typed str y = 99       : y is "99" (string)
typed float z = "3.14" : z is 3.14

Type Errors

Incompatible conversions fail at runtime:

try {
    typed int x = "hello"
} catch e {
    write("error: ", e.msg)
}

Output: error: cannot convert 'hello' to number

Function Annotations

Functions can annotate parameter types and return type:

#add(a typed int, b typed int) typed int {
    a + b
}

The runtime checks parameter types at call time and coerces the return value:

typed str s = "42"
typed int n = add(s, 1)  : n is 43

Why typed instead of x: int?

lil uses : for single-line comments (: this is a comment). Using x: int syntax would conflict. The typed keyword follows the same pattern as force/unforce:

  • force x - mark variable as immutable
  • typed int x - mark variable as typed

Standalone Conversion

The intify, stringify, and toggle forms still work for in-place conversion without annotations:

x = "hello"
intify x
: x is now 0 (conversion failed, defaults to 0)

y = 99
stringify y
: y is now "99"

Lists

Lists are ordered collections of values. They are created with square bracket syntax and support index access.

Creating Lists

x = [1, 2, 3]
mixed = ["hello", 42, 99]
empty = []

Index Access

Access elements by position (0-indexed):

x = [10, 20, 30]
write(x[0])
write(x[1])
x[2] = 99

String indexing works the same way. s[i] returns a single-character string, and s[i] = "x" replaces a character:

s = "hello"
write(s[0])          : "h"
s[0] = "j"
write(s)             : "jello"

Out-of-range access errors at runtime.

Destructuring

Pull multiple fields from a dict or struct in one line with {field1, field2} = expr:

node = {"type": "num", "value": 42}
{type, value} = node
write(type)
write(value)

Works with structs too (structs are dicts underneath):

struct Point { x, y }
p = Point(10, 20)
{x, y} = p
write(x, y)

This is shorthand for type = node["type"]; value = node["value"]. Field names become variable names.

List Library

The list library provides operations for dynamic list manipulation:

include list

x = new@list
push@list(x, 1)
push@list(x, 2)
push@list(x, 3)
write(len@list(x))
write(x[1])
val = pop@list(x)

Functions

  • new@list - create empty list
  • push@list(list, val) - append value to end
  • pop@list(list) - remove and return last element
  • len@list(list) - number of elements
  • map@list(list, fn) - apply function to each element, returns new list
  • filter@list(list, fn) - keep elements where function returns truthy
  • reduce@list(list, fn, init) - reduce list to single value

Read individual elements with bracket syntax: x[i] returns the value at position i (0-indexed).

map/filter/reduce

These higher-order functions take a user-defined function and apply it to each element:

include list

#double(x) {
    x * 2
}

nums = new@list
push@list(nums, 1)
push@list(nums, 2)
push@list(nums, 3)

doubled = map@list(nums, double)
write(doubled)

filter keeps elements where the function returns truthy:

#isbig(x) {
    if x > 5 { 1 } else { 0 }
}

filtered = filter@list(nums, isbig)
write(filtered)

reduce combines all elements using a two-parameter function and an initial value:

#add(a, b) {
    a + b
}

total = reduce@list(nums, add, 0)
write(total)

Advanced Features

This chapter covers the advanced features of lil: templates, the has operator, compiled mode, Minecraft Forge mod generation, and C extensions. If you know Python, many of these concepts will feel familiar under a different syntax.

What You’ll Find Here

TopicPython AnalogyWhat It Does
Templatesf-strings like f"hello {name}"Inline variable substitution in strings
Has operatorin keyword like "sub" in textCheck variable existence or substring match
Compiled modeCython / NuitkaCompile lil scripts to standalone C binaries
Minecraft ForgeN/AGenerate complete Minecraft Forge mod Java source
C extensionsctypes / cffiDirect memory access with address-of and dereference

Each topic is self-contained. Jump to whatever you need:

These features are considered advanced because they either:

  • Only work in specific modes (compiled mode for C extensions, interpreted mode for has)
  • Change how you structure your code (templates vs string concatenation)
  • Expose low-level behavior (memory addresses, direct compilation)

Start with templates if you want easier string building, or jump to compiled mode if you need raw speed.

Compiled Mode

lil can compile scripts into standalone binaries (use -c). The resulting executable does not need lil to run. Think of it like Cython for Python: same source, faster execution, no interpreter required.

Note: There is also a Java/Forge output mode (-j for full tree, -jc for flat) for generating Minecraft mod source. See Minecraft Forge Generator.

How it works

When you compile, lil:

  1. Reads and parses the script
  2. Infers variable types (numeric variables become C doubles)
  3. Generates a C file containing the program logic and a minimal runtime
  4. Compiles the C file with gcc into a standalone binary

Usage

Write a script:

#fib(n) {
  if n <= 2 {
    1
  } else {
    fib(n - 1) + fib(n - 2)
  }
}

result = fib(35)
write("fib(35) = ", result)

Compile and run:

lil -c fib.lil -o fib
./fib

If you omit -o, the output name is derived from the input filename (the .lil extension is removed). If that fails, it defaults to a.out.

Standalone Binary

The compiled binary is self-contained. Copy it to another machine (same architecture) and it runs without lil installed:

scp fib user@other-machine:~
ssh user@other-machine ./fib    : works without lil

Performance

Compiled mode is significantly faster than interpreted mode, especially for numeric code. The type inference pass eliminates the overhead of dynamic type checks for variables that are proven to always hold numbers.

benchmarkinterpretedcompiledC
loop 50M0.56s0.05s0.05s
primes 5k0.05s0.005s0.004s
fib(35)4.2s0.4s0.3s

Compare for yourself:

time lil fib.lil
time ./fib

Compiled code matches C performance on simple numeric loops. The fib(35) recursive function runs roughly 10x faster when compiled.

Functions

User-defined functions (#name()) work in compiled mode. Each function becomes a standalone C function with its own local variable scope and type inference. Function calls work inside expressions, control flow, and can be recursive.

What Compiles (and What Doesn’t)

Simple operations, arithmetic, control flow, user-defined functions, and variable assignments all compile fine:

x = 42
y = x + 1
write(y)

This WILL compile and run correctly.

Library calls like full@date will NOT compile. The compiler generates a stub that returns a default value (0 for numbers, empty string for strings):

write(full@date)          : compiles but prints nothing useful
write(cmd@sys("ls"))      : returns 0 in compiled mode
write("hello")             : this compiles and works fine

Limitations

The following features are not available in compiled mode:

  • The has operator in conditions
  • Templates (template strings)
  • Library function calls (func(args)@lib)

If your script uses any of these features, compilation will fail and lil will tell you which construct could not be compiled.

Short-circuit && and ; in compiled mode

&& and ; work in compiled mode but fall back to the interpreter for execution. They work correctly but don’t get the full AOT optimization.

Modifiers in AOT

force and unforce still parse and run in compiled mode, but the locking concept does not apply. Variables in the generated C are statically typed, so force x = 42 simply becomes double x = 42; (or the inferred type) with no lock attached. A later x = 30 in the same compiled script will not be rejected the way it would in the interpreter. unforce x compiles to a no-op.

Minecraft Forge Mod Generator

lil can generate complete Minecraft Forge mods from a high-level DSL. Use -j to enable Java generation and -o <dir>/ to write a full mod directory tree. Use -jc for flat output (files go directly in the target dir without the src/main/java/... tree).

Quick Start

[MCMForge]
include minecraft-forge

modID@minecraft = "example_mod"
modVersion@minecraft = "0.1.0"
mcVersion@minecraft = "1.20.1"
modName@minecraft = "Example Mod"
modAuthors@minecraft = "you"

Compile: lil -j mod.lil -o mod_out/

For flat output (no src/main/java/com/... tree):

lil -jc mod.lil -o mod_out/

Metadata

DirectiveDescription
modID@minecraftMod identifier (used in file paths, registry names)
modVersion@minecraftMod version
mcVersion@minecraftMinecraft version target
forgeVersion@minecraftForge version (default: 47.3.0)
modName@minecraftHuman-readable mod name
modAuthors@minecraftAuthor string
modLicense@minecraftMod license (default: “All Rights Reserved”)

Items

Basic Items

newItem@minecraft(ruby) {
    propertiesItem@minecraft {
        stacksTo = 64
    }
    itemNames@minecraft {
        [1] = "Ruby"
    }
}

Tools (automatic type detection)

Names containing _sword, _pickaxe, _axe, _shovel, or _hoe are automatically typed:

newItem@minecraft(ruby_sword, ruby_pickaxe, ruby_axe) {
    propertiesItem@minecraft {
        tier = "ruby"
        attackDamage = 3
        attackSpeed = -2.4
    }
    itemNames@minecraft {
        [1] = "Ruby Sword"
        [2] = "Ruby Pickaxe"
        [3] = "Ruby Axe"
    }
}

Armor

Armor names (_helmet, _chestplate, _leggings, _boots) are detected:

newItem@minecraft(ruby_helmet, ruby_boots) {
    propertiesItem@minecraft {
        tier = "ruby"
        durability = 37
        protection = 3, 6, 8, 3
        toughness = 2.0
    }
    itemNames@minecraft {
        [1] = "Ruby Helmet"
        [2] = "Ruby Boots"
    }
}

Food

newFood@minecraft(ruby_apple) {
    propertiesItem@minecraft {
        hunger = 6
        saturation = 0.6
        alwaysEdible = true
    }
    foodEffect@minecraft("NIGHT_VISION", 6000, 0, 1.0)
    foodEffect@minecraft("REGENERATION", 100, 1, 1.0)
}

Properties

PropertyApplies ToTypeDescription
tiertools, armorstringTool tier (e.g. “ruby”, “diamond”)
attackDamagetoolsnumberBase attack damage
attackSpeedtoolsnumberAttack speed modifier
durabilityanynumberItem durability
stacksToitemsnumberMax stack size
isImmuneToLavaany0/1Fire resistance
hungerfoodnumberHunger restored
saturationfoodnumberSaturation modifier
alwaysEdiblefood0/1Edible when full
protectionarmorlistPer-slot protection values
toughnessarmornumberArmor toughness

Blocks

newBlock@minecraft(ruby_block, ruby_ore) {
    propertiesBlock@minecraft {
        material = "metal"
        hardness = 5.0
        resistance = 6.0
        tool = "pickaxe"
        tier = "iron"
        sound = "metal"
    }
    itemNames@minecraft {
        [1] = "Ruby Block"
        [2] = "Ruby Ore"
    }
}

Block Properties

PropertyValuesDescription
materialmetal, wood, stone, glass, dirt, plant, snowBase material
hardnessnumberMining hardness
resistancenumberExplosion resistance
toolpickaxe, axe, shovel, hoeRequired tool type
tierstone, iron, diamondRequired tool tier
soundmetal, wood, stone, glass, deepslate, gravel, snowBlock sound type
requiresTool0/1Tool required for drops
lightnumber (0-15)Light emission level

Recipes

Shaped

recipeShaped@minecraft(ruby_sword) {
    pattern {
        "", "R", "",
        "", "R", "",
        "", "S", ""
    }
    keys = "R": "ruby", "S": "minecraft:stick"
    result = "ruby_sword"
}

Pattern cells auto-group: 9 cells = 3x3 grid, 4 cells = 2x2 grid, anything else = 1 row per cell (old format " R " also works). Empty string "" = empty slot in the grid.

Item references in keys ("R": "ruby"), results, and ingredients default to the current mod’s namespace: "ruby" becomes "lilmod:ruby". Prepend a modid with : to reference items from another mod or vanilla: "minecraft:stick", "othermod:ruby". This applies everywhere item IDs are used in recipes.

For comparison, the old format also works (same result):

recipeShaped@minecraft(ruby_sword) {
    pattern {
        " R ",
        " R ",
        " S "
    }
    keys = "R": "ruby", "S": "minecraft:stick"
    result = "ruby_sword"
}

Shapeless

recipeShapeless@minecraft(ruby) {
    ingredients = "ruby_block"
    result = "ruby"
    count = 9
}

Smelting

recipeSmelting@minecraft(ruby) {
    ingredient = "raw_ruby"
    result = "ruby"
    experience = 1.0
    cookTime = 200
}

Creative Tabs

newCreativeTab@minecraft(ruby_tab) {
    propertiesCreativeTab@minecraft {
        icon = "ruby_sword"
        items = "ruby", "ruby_sword", "ruby_block", "ruby_apple"
    }
}

Properties

PropertyDescription
iconItem to use as tab icon
itemsComma-separated list of items to display in the tab

Display Names

Use creativeTabNames@minecraft inside the tab block to set per-tab display names (indexed by parameter position):

newCreativeTab@minecraft(materials_tab, combat_tab) {
    propertiesCreativeTab@minecraft {
        icon = "ruby"
        items = "ruby", "ruby_block", "ruby_sword", "ruby_helmet"
    }
    creativeTabNames@minecraft {
        [1] = "Materials"
        [2] = "Combat"
    }
}

If no display name is set, the tab’s internal name is used as-is in the lang file.

Procedure Events

Items and blocks can have custom behavior via procedure blocks:

Item Events

procedureItem@minecraft(HitEntity) {
    write@std "hit something"
}
Event NameOverride Method
RightClickInteractionResultHolder<ItemStack> use(Level, Player, InteractionHand)
HitEntityboolean hurtEnemy(ItemStack, LivingEntity, LivingEntity)
Craftedvoid onCraftedBy(ItemStack, Level, Player)

Block Events

procedureBlock@minecraft(BlockPlaced) {
    write@std "placed"
}
Event NameOverride Method
BlockPlacedvoid onPlace(BlockState, Level, BlockPos, BlockState, boolean)

Custom Models

Override auto-generated item and block model JSONs with modelsItem@minecraft and modelsBlock@minecraft.

Item Models

modelsItem@minecraft {
    [1] parent = "handheld"
    [1] layer0 = "ruby_sword"
}

Each [N] entry targets item index N (1-based, same order as in newItem@minecraft). parent sets the model parent; any other key becomes a texture variable in the generated JSON. Texture paths without a colon or slash get prefixed with {modid}:item/.

In the JSON output, this produces a custom model instead of the generic item/generated or item/handheld:

{
  "parent": "item/handheld",
  "textures": {
    "layer0": "example_mod:item/ruby_sword"
  }
}

Block Models

modelsBlock@minecraft {
    [1] parent = "cube_column"
    [1] end = "example_block_top"
    [1] side = "example_block_side"
}

Same format as item models. Texture paths without a colon or slash get prefixed with {modid}:block/ instead of item/. Omitting both modelsItem and modelsBlock produces standard auto-generated models (item/generated, block/cube_all).

Auto-Generated Textures

If you define a custom model referencing a texture file (e.g. layer0 = "ruby_sword"), and no .png file exists at the expected path, lil auto-generates a 16x16 placeholder PNG with a color derived from the texture name. This lets you test the mod in-game before creating real textures.

The expected paths are:

  • Item textures: src/main/resources/assets/{modid}/textures/item/{name}.png
  • Block textures: src/main/resources/assets/{modid}/textures/block/{name}.png

Generated Files

When using -o <dir>/, lil produces a full mod source tree:

<dir>/
  build.gradle
  gradle.properties
  src/main/java/com/{modid}/
    ModMain.java
    ModItems.java
    ...
  src/main/resources/
    ...

With -jc (flat mode), Java files go directly in <dir>/ without the src/main/java/com/{modid}/ nesting. Resources and build config keep their same paths:

<dir>/
  build.gradle
  gradle.properties
  ModMain.java
  ModItems.java
  ModBlocks.java
  ModCreativeTabs.java
  ModArmorMaterials.java
  ModToolTiers.java
  {ClassName}.java           (one per item/block)
  src/main/resources/
    META-INF/mods.toml
    pack.mcmeta
    assets/{modid}/
      lang/en_us.json
      models/item/{name}.json
      models/block/{name}.json
      blockstates/{name}.json
    data/{modid}/
      recipes/{name}.json
      loot_tables/blocks/{name}.json
      tags/blocks/mineable/{tool}.json
      tags/blocks/needs_{tier}_tool.json

C Extensions

lil provides two operators that give you direct access to memory. These only work when compiling with lil -c. Running them with the interpreter produces an error.

If you’ve used Python’s ctypes module to get the address of a C object or read memory at a raw pointer, the concept is the same: @ is like ctypes.addressof() and ^ is like ctypes.string_at().

Address-of (@)

The @ operator returns the memory address of a variable as a number:

x = 42
addr = @x
write(addr)

The address will be different every time you run the program (ASLR). One run might print 140732789041152, the next 140732789041184.

You can also get addresses of string variables:

msg = "hello"
msg_addr = @msg
write(msg_addr)

Dereference (^)

The ^ operator reads a value from a memory address:

x = 42
addr = @x
value = ^addr
write(value)    : prints 42

This round-trips through a pointer: write a value, take its address, then dereference it back.

Practical Example

x = 42
x_addr = @x
x_val = ^x_addr
write("address: ", x_addr)
write("value: ", x_val)

Each run will show a different address but always the same value.

Safety Warning

Dereferencing an invalid address will crash your program with a segfault, just like Python’s ctypes.string_at(bad_address):

value = ^9999999    : almost certainly a segfault

These operators are useful for low-level programming and interfacing with hardware at specific memory addresses (MMIO). Use them carefully.

In the interpreter (lil file.lil), both operators produce an error.

GTK GUI Library

lil has a built-in GTK+3 library for graphical user interfaces.

Widgets are referenced by string names, and events set lil variables that you poll in a loop. No callback functions needed.

Building with GTK

The default make builds lil without GTK support. Use make gtk to include it:

make gtk
make install-gtk    : install the gtk-enabled binary to PATH

This requires libgtk-3-dev (or your distro’s GTK+3 development package).

Important: String Literals Required

All GTK function arguments (widget names, property names, signal names, variable names) must be quoted strings. Bare identifiers are evaluated as variables, which will cause errors.

window@gtk("win", "hello", 400, 300)    : correct
window@gtk(win, "hello", 400, 300)      : WRONG - win evaluated as variable

Widgets

Create widgets with func@gtk commands. Every widget gets a string name you choose:

window@gtk("main", "hello", 400, 300)    : name, title, width, height
vbox@gtk("box", 10)                     : vertical box with 10px spacing
hbox@gtk("row", 5)                      : horizontal box with 5px spacing
button@gtk("btn", "click me")           : button with label
label@gtk("lbl", "hello world")         : static text label
entry@gtk("input", "type here...")      : text entry with placeholder

Widgets are referred to by string names, not objects.

Layout

Build a hierarchy by adding children to containers:

add@gtk("box", "btn", "lbl", "input")    : add multiple children at once
show@gtk("box")                        : show the container and all children

Properties

Set and get widget properties at runtime:

set@gtk("lbl", "label", "new text")      : change button/label text
set@gtk("input", "text", "hello")        : change entry text
set@gtk("main", "title", "my app")       : change window title
set@gtk("btn", "sensitive", "false")     : disable a widget

Get current property values:

current = get@gtk("lbl", "label")
write(current)

Available properties: label, text, title, placeholder, sensitive, visible, width, height.

Events (no callbacks)

Instead of callback functions, GTK signals set lil variables. You poll them in a loop.

Critical: The third argument to on@gtk must be a quoted string containing the variable name. Bare identifiers are evaluated as variables (their value is used, not their name).

on@gtk("btn", "clicked", "ev")
on@gtk("main", "destroy", "closed")
show@gtk("main")

loop {
  wait@gtk          : blocks until any registered signal fires
  if closed == 1 { break }
  if ev == 1 {
    set@gtk("btn", "label", "clicked!")
    ev = 0
  }
}

wait@gtk blocks until a signal fires. Then you check which variable was set and handle it. Set the variable back to 0 after handling it.

Timeout events

Fire a signal after a delay:

timeout@gtk(1000, "tick", "t")    : fire 'tick' after 1000ms, sets t

Complete Example

A window with a button that counts clicks:

include gtk

window@gtk("win", "Counter", 300, 200)
vbox@gtk("box", 10)
button@gtk("btn", "clicks: 0")
add@gtk("box", "btn")
add@gtk("win", "box")
on@gtk("btn", "clicked", "ev")
on@gtk("win", "destroy", "closed")
show@gtk("win")

count = 0
loop {
  wait@gtk
  if closed == 1 { break }
  if ev == 1 {
    count = count + 1
    set@gtk("btn", "label", "clicks: {count}")
    ev = 0
  }
}

Functions Reference

FunctionDescription
window@gtk(name, title, w, h)Create window
vbox@gtk(name, spacing)Vertical box
hbox@gtk(name, spacing)Horizontal box
button@gtk(name, label)Button
label@gtk(name, text)Label
entry@gtk(name, [placeholder])Text entry
add@gtk(parent, child, ...)Add children to container
set@gtk(widget, prop, val)Set property
get@gtk(widget, prop)Get property (returns string)
on@gtk(widget, signal, var)Register event, sets var on trigger
wait@gtkBlock until next signal
run@gtkNon-blocking signal check
timeout@gtk(ms, signal, var)Fire signal after delay
show@gtk(name)Show widget
quit@gtkQuit GTK main loop
destroy@gtk(name)Destroy widget

All arguments must be quoted strings. Signals: clicked, changed, activate, enter-notify-event, leave-notify-event, focus-in-event, focus-out-event, destroy

Dynamic Eval

eval@sys lets you execute lil code from a string at runtime:

include sys

eval@sys("x = 42")
write(x)

You can capture the return value:

include sys
result = eval@sys("1 + 2")
write(result)

Eval uses the same parser and interpreter as normal execution. Variables set inside eval are visible to the caller afterwards.

Errors during eval are caught silently and return 0.

Standard Library

The lib/ directory contains reusable lil modules loaded with include.

Loading

include lib/extra.lil/sum filter

Modules

extra.lil

  • #sum(list) - add all elements
  • #range_list(start end) - build list of numbers
  • #range(start end) - print numbers

Built-in FFI Errors

Functions that fail silently set a last error message:

include file
include sys
x = read@file("/nonexistent")
write(last_error@sys)
clear_error@sys
write(last_error@sys)
  • last_error@sys - returns last error string (empty if none)
  • clear_error@sys - resets error state
  • log@sys("msg") - prints to stderr for debugging