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
selfparameter. No__init__. No classes at all. - No
returnkeyword. Functions return the value of their last expression. - No
importstatement. Useincludeto load other script files. - Variables are dynamically typed (like Python), but the compiler infers types for C codegen.
What to Read Next
- Installation - build and install lil
- Hello, World - your first lil script
- Basics - syntax overview
- What is lil? - language history and design goals
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:
;orandnot==,!=,<,>,<=,>=+,-*,/,%,//- unary
-, unarynot - 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.
Substring search
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
| flag | meaning |
|---|---|
nocase | case insensitive search |
anywhere | search anywhere in the string (default) |
word | match 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:
- While loops - loop while a condition holds
- For loops - iterate over a numeric range
- Loop blocks - infinite loop with explicit exit
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 occurrede.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
- Undefined Default - change what undefined variables return
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 immutabletyped 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 listpush@list(list, val)- append value to endpop@list(list)- remove and return last elementlen@list(list)- number of elementsmap@list(list, fn)- apply function to each element, returns new listfilter@list(list, fn)- keep elements where function returns truthyreduce@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
| Topic | Python Analogy | What It Does |
|---|---|---|
| Templates | f-strings like f"hello {name}" | Inline variable substitution in strings |
| Has operator | in keyword like "sub" in text | Check variable existence or substring match |
| Compiled mode | Cython / Nuitka | Compile lil scripts to standalone C binaries |
| Minecraft Forge | N/A | Generate complete Minecraft Forge mod Java source |
| C extensions | ctypes / cffi | Direct memory access with address-of and dereference |
Each topic is self-contained. Jump to whatever you need:
- Templates - string interpolation with
{varname} - Has operator - substring matching and variable existence checks
- Compiled mode - AOT compilation for 10x speedup
- Minecraft Forge - Java mod source generation
- C extensions - low-level memory operations with
@and^
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:
- Reads and parses the script
- Infers variable types (numeric variables become C doubles)
- Generates a C file containing the program logic and a minimal runtime
- 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.
| benchmark | interpreted | compiled | C |
|---|---|---|---|
| loop 50M | 0.56s | 0.05s | 0.05s |
| primes 5k | 0.05s | 0.005s | 0.004s |
| fib(35) | 4.2s | 0.4s | 0.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
hasoperator 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
| Directive | Description |
|---|---|
modID@minecraft | Mod identifier (used in file paths, registry names) |
modVersion@minecraft | Mod version |
mcVersion@minecraft | Minecraft version target |
forgeVersion@minecraft | Forge version (default: 47.3.0) |
modName@minecraft | Human-readable mod name |
modAuthors@minecraft | Author string |
modLicense@minecraft | Mod 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
| Property | Applies To | Type | Description |
|---|---|---|---|
tier | tools, armor | string | Tool tier (e.g. “ruby”, “diamond”) |
attackDamage | tools | number | Base attack damage |
attackSpeed | tools | number | Attack speed modifier |
durability | any | number | Item durability |
stacksTo | items | number | Max stack size |
isImmuneToLava | any | 0/1 | Fire resistance |
hunger | food | number | Hunger restored |
saturation | food | number | Saturation modifier |
alwaysEdible | food | 0/1 | Edible when full |
protection | armor | list | Per-slot protection values |
toughness | armor | number | Armor 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
| Property | Values | Description |
|---|---|---|
material | metal, wood, stone, glass, dirt, plant, snow | Base material |
hardness | number | Mining hardness |
resistance | number | Explosion resistance |
tool | pickaxe, axe, shovel, hoe | Required tool type |
tier | stone, iron, diamond | Required tool tier |
sound | metal, wood, stone, glass, deepslate, gravel, snow | Block sound type |
requiresTool | 0/1 | Tool required for drops |
light | number (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
| Property | Description |
|---|---|
icon | Item to use as tab icon |
items | Comma-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 Name | Override Method |
|---|---|
RightClick | InteractionResultHolder<ItemStack> use(Level, Player, InteractionHand) |
HitEntity | boolean hurtEnemy(ItemStack, LivingEntity, LivingEntity) |
Crafted | void onCraftedBy(ItemStack, Level, Player) |
Block Events
procedureBlock@minecraft(BlockPlaced) {
write@std "placed"
}
| Event Name | Override Method |
|---|---|
BlockPlaced | void 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
| Function | Description |
|---|---|
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@gtk | Block until next signal |
run@gtk | Non-blocking signal check |
timeout@gtk(ms, signal, var) | Fire signal after delay |
show@gtk(name) | Show widget |
quit@gtk | Quit 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 statelog@sys("msg")- prints to stderr for debugging