tyro
Lua and other programming in simple platform to make programming fun
- Active · last change 11-5-2026
- ★ 21 on GitHub
README
Tyro
Runing embed programming language in simple graphical environment, for kids and newbies, using raylib as small game engine to draw.
Currently supports Lua (.tyro / .lua).
It also should have editing tool inside that environment, console output and input, work in same graphical window.
playing sound using mmf code.
It is More simulating old computer, but with modern languages and graphic.
Command Line
tyro [<script>] [--workpath=<dir>] [<options>]Running a .tyro/.lua file executes it in a graphical environment. Without a
script argument Tyro starts an interactive console session.
| Option | Description |
|---|---|
--help, -h |
Show the help page and exit |
--console, -c |
Force a command prompt / console output |
--debug, -d |
Enable debug logging |
--exit[:N] |
Exit after N seconds, or — with a bare --exit (no :N) — as soon as the script finishes |
--screenshot:N |
Save screenshot.png N seconds into the run; opens a window so the screen can be captured |
--workpath=<path> |
Workspace directory (defaults to the executable location) |
With an engine-driven script — one that defines update/draw (see
Timing) — the script "finishes" the moment its top-level chunk
returns, so a bare --exit ends the run right away, before the callbacks get
to run. Use --exit:N to let such a script play for N seconds.
Exit statuses
| Status | Meaning |
|---|---|
0 |
Success (also --help/--list, and interactive sessions closed normally) |
1 |
Lua runtime error while running with --exit |
Lua Example
canvas.text(10, 30, 'Printing text test')
i = 1000000
canvas.color = colors.black
canvas.line(0, 100, canvas.width, 100)
while i > 0 do
c = math.random(3, colors.count)
canvas.color = colors[c]
r = math.random(5, 20) --size of circle
x = math.random(640)
y = math.random(480)
canvas.circle(x, y, r, true)
r = math.random(5, 20) --size of circle
x = math.random(640)
y = math.random(480)
canvas.rectangle(x, y, r, r, true)
sleep(10)
i = i - 1
endThe canvas primitives are canvas.clear(), canvas.text(x, y, s),
canvas.rectangle(x, y, w, h [, fill]), canvas.circle(x, y, r [, fill]),
canvas.triangle(x1, y1, x2, y2, x3, y3 [, fill]),
canvas.line(x1, y1, x2, y2 [, thickness]) and canvas.point(x, y).
canvas.gradient.rectV(x, y, w, h [, startColor, endColor]) fills a rectangle with
a top-to-bottom gradient, canvas.gradient.rectH(...) with a left-to-right one,
and canvas.gradient.circle(x, y, r [, innerColor, outerColor]) fills a circle
with a radial gradient (inner colour at the centre, outer at the edge);
their colors take the same colors.* values as canvas.color and default to
the current color when omitted. Set the color with canvas.color = colors.red
(and canvas.backcolor / canvas.clear() for the background).
canvas.pensize (alias canvas.linewidth) sets the
thickness of lines and of rectangle/triangle outlines; canvas.line also takes
a per-call thickness. textwidth(s) and textheight() measure the built-in
font, and canvas.width / canvas.height report the canvas size.
canvas.test(ms) is a diagnostic draw: it draws a marker rectangle and then
spends about ms milliseconds on the main thread, emulating a single draw call
slower than a frame. It exists to check that a body grouped with enter() /
leave() is still drawn as one group (see docs/atomic.md);
normal scripts do not need it.
For sound, music.beep(), music.sound(freq, ms) or music.sound(wave, freq, ms), music.noise(ms) (white) / music.noise(freq, ms) (pitched) and
music.mml(...) play one-shot effects and tunes. The music.volume property
(0..1) sets the level of the music.mml melodies only - one-shot sounds
and loops keep their own volume - and reading it returns the current level.
For a sound that has to run
without gaps—an engine drone, a siren, a wind loop—use sound.loopPlay(freq [, volume]) or sound.loopPlay(wave, freq [, volume]). It keeps looping until
sound.loopStop(), and sound.loopPitch(freq) / sound.loopVolume(0..1) retune
it while it plays. Available wave names include Sin, Square, Saw, Tri,
Organ, FM, Noise, Pink, Engine, the game effects Fire, Boing,
Siren and Bubble, and the drum waveforms Kick, Snare and HiHat.
For a one-shot sound effect from an audio file, sound.play("file.wav" [, volume])
plays the file (raylib LoadSound, .wav) once. The file is resolved like every
other script asset (next to the script, the workspace, the app folder); a missing
file is ignored, not an error. Use sound.load("file.wav") to decode a file ahead
of time and keep it cached (so the first sound.play is instant), and
sound.unload("file.wav") to drop it from the cache again. A preloaded file is
played as a cheap alias of the cached copy; a file that was never loaded is decoded
on the spot and released once it finishes, so sound.play on its own does not keep
anything cached.
Input
Keyboard and mouse input is exposed as the keyboard, mouse and gamepad
tables. Key and button names are strings that map to RayLib key/button enums —
they work identically in Lua and PascalScript.
Keyboard
| API | Description | Examples |
|---|---|---|
keyboard.pressed(key) |
True once when the key transitions from released to pressed | "space", "w", "f1" |
keyboard.down(key) |
True every frame while the key is held | "ctrl", "up", "a" |
Supported key names: letters a-z, digits 0-9, space, enter, tab,
escape, backspace, delete, insert, up, down, left, right,
f1-f12, shift, ctrl, alt, minus, equals (the + key is the
shifted =, so ask for plus to catch it), kp_plus, kp_minus.
Engine shortcuts
These work while the engine is running, whatever has the keyboard focus:
| Key | Description |
|---|---|
F2 |
Toggle the script editor. Opening it stops the script; closing it (also with ESC) saves the edited source back to the script and runs it |
F4 |
Toggle the script picker (*.tyro and *.lua) |
F5 |
Rerun the loaded script on a new script thread |
F7 |
Toggle the output panel |
F8 |
Toggle the console |
CTRL+F10 |
Toggle the queue/frame statistics panel in the top-left corner |
CTRL+S |
In the editor: save the source back and rerun the script |
The script that is edited and rerun is the one loaded with load or picked with
F4; every run gets a fresh copy of its source, so the editor never changes a
running script. A run starts from a clean window: the controls and the sprites the
previous run created are freed and both canvases (board and window) are cleared,
while the console, the output panel, the editor and the F4 picker stay put.
window.show()
while true do
if keyboard.down("w") then print("W is held") end
if keyboard.pressed("space") then print("Space pressed!") end
sleep(16)
endMouse
| API | Description | Examples |
|---|---|---|
mouse.x |
Current mouse X position | — |
mouse.y |
Current mouse Y position | — |
mouse.pressed([button]) |
True once when a mouse button is pressed (default "left") |
"left", "right", "middle" |
window.show()
while true do
if mouse.pressed("left") then
canvas.circle(mouse.x, mouse.y, 5, true)
end
sleep(10)
endGamepad
| API | Description | Examples |
|---|---|---|
gamepad.available([gamepad]) |
True when a gamepad is connected (default pad 0) | — |
gamepad.name([gamepad]) |
The pad's name, or empty when there is no pad | — |
gamepad.pressed(button [, gamepad]) |
True once when a pad button transitions to pressed | "a", "start", "dpad_up" |
gamepad.down(button [, gamepad]) |
True every frame while a pad button is held | "a", "lb", "dpad_left" |
gamepad.axis(axis [, gamepad]) |
Axis value in -1..1 (0 without a pad) | "leftx", "lefty", "rightx", "righty", "ltrigger", "rtrigger" |
gamepad.mappings(text) |
Add gamepad mappings in SDL/GLFW format; returns nonzero when accepted | — |
Supported button names: a, b, x, y, lb, rb, lt, rt, start,
select, guide, lstick, rstick, dpad_up, dpad_down, dpad_left,
dpad_right.
Xbox and PlayStation pads work out of the box. Generic or no-name pads often
have no built-in mapping, so every button and axis reads as released even
though Windows itself sees the controller. In that case drop a
gamecontrollerdb.txt (the SDL controller database) and/or a
gamepadmappings.txt next to tyro.exe. They hold SDL/GLFW mapping lines
(<GUID>,<name>,<controls>, one per controller) that are loaded at startup for
every script; the database is read first, then gamepadmappings.txt, so your
own lines override it for the same GUID. gamepad.mappings(text) does the same
from a script. A GUID copied from an SDL 2.0.5+ controller database (the form
that carries a crc: field) is converted automatically, so its line can be
pasted unchanged.
Files
Scripts have no direct file system access: the Lua io and os libraries are
removed from the sandbox, so io.open and friends do not exist. openfile is
the only way in, and it hands out a handle only for a file that resolves inside
the workspace.
local f, err = openfile("scores.txt", "w") -- relative names land next to the script
if not f then
println("cannot write scores.txt: " .. err)
return
end
f:write("hello\n")
f:close()openfile(name [, mode]) returns a handle, or nil plus a message — it never
raises an error. mode takes the io.open letters: r (default), w, a,
x and +, with b for binary. Without b the handle is a text handle and
translates \n to \r\n on the way out and back on the way in.
| Function | Description |
|---|---|
openfile(name [, mode]) |
Open a workspace file; returns the handle, or nil plus a message |
f:read([what]) |
"l" a line, "L" a line keeping its newline, "a" what is left, "n" the next number, or a byte count. Returns nil at the end of the file. |
f:write(...) |
Write the arguments, returns f so calls chain. A leading number writes only that many bytes of the string after it. |
f:lines() |
Iterate the lines that are left: for line in f:lines() do ... end |
f:flush() |
Push anything buffered to the disk, returns f so calls chain |
f:close() |
Close the handle and return true; further reads and writes report the file as closed |
The name is expanded before it is checked, so .., a drive-relative form or a
UNC path cannot walk out of the workspace:
openfile("../secrets.txt") --> nil, "resolves outside the workspace"
openfile("sub/../data.txt") --> fine, it stays insideSee tests/test_openfile.tyro for a runnable check of all of it.
Network
Scripts can talk to web APIs with http_get and read the answer with
json_decode. The request runs on the script thread, so the render loop keeps
drawing while the call is in flight; the script itself blocks until the answer
(or the failure) arrives.
| Function | Description |
|---|---|
http_get(url) |
Fetch a URL over HTTP/HTTPS; returns the body, or nil plus a message |
json_decode(text) |
Parse a JSON document into the matching Lua value: object→table (keyed by member name), array→table (1..n), number, boolean, null→nil; returns nil plus the parser message on invalid input |
local body, err = http_get("https://ipapi.co/json/") -- IP-based geolocation
if body then
local geo = json_decode(body)
println("you are in " .. geo.city .. ", " .. geo.country_code)
endjson_decode answers nil plus a message for a bare top-level number or
boolean ("42", "false") — the underlying parser reads objects, arrays and
strings. See games/weather/weather.tyro for a full app built on both
functions, and tests/test_http_json.tyro for a runnable check.
Console
Built-in Terminal Commands
When the graphical console is visible, you can type commands at the > prompt.
The following commands are built in:
| Command | Description |
|---|---|
dir, list, ls |
List files in the current directory |
clear, cls |
Clear the console output |
help, ? |
Show available commands |
load <script> |
Load a script from the current directory (F4 to pick one) |
run |
Run the loaded script (like F5; reruns it when it is already running) |
stop |
Stop the running script |
state |
Show which script is loaded and whether it is running |
edit |
Open the script editor (like F2) |
exit, quit |
Hide the console and stop the engine |
ESC |
Hide the console (keybinding) |
Example:
tyro demos/terminal_demo.lua
# then type "dir" at the console promptHolding a key auto-repeats it at the prompt: typed characters, BACKSPACE,
DELETE, the arrow keys and HOME/END keep repeating after a short delay.
ENTER, ESC and CTRL shortcuts stay one-shot.
Text at the prompt can be selected with SHIFT+LEFT/RIGHT/HOME/END (a
plain arrow press jumps to the edge of the selection), with CTRL+A, or by
dragging with the mouse. Typing, BACKSPACE, DELETE or pasting replaces the
selection, and it is copied/pasted with CTRL+C / CTRL+V or with the
traditional console keys CTRL+INSERT / SHIFT+INSERT.
Console API
| Function | Description |
|---|---|
console.show([w, h]) |
Show the console (optionally sized in characters) |
console.print(text) |
Print text to the console (no newline) |
console.println(text) |
Print text to the console (with newline) |
console.read([prompt]) |
Block and read a line of input from the user |
console.active |
Read-only boolean — true while the console is visible |
Interactive Reading (console.read)
console.read() displays a prompt on the console and blocks the script until
the user types a line and presses Enter. It returns the typed string.
console.show()
local name = console.read("Your name? ")
println("Hello, " .. name .. "!")See demos/terminal_demo.tyro and demos/console_read_demo.tyro for examples.
Sprites
The Sprites system manages textured images ("sprites") that the engine draws
every frame. Each sprite is created with Sprites.new, loaded with load, and
positioned with move or by setting x/y properties.
Creating & Loading
| Function | Description |
|---|---|
Sprites.new("name"?) |
Create a new sprite. Optional name registers it for lookup. |
Sprites.puff("name"? [, size] [, count]) |
Create a sprite carrying an engine-built soft puff texture (no file, like models.sphere): a solid core with a feathered, lumpy rim — the arcade building block for clouds and smoke. size is the texture in pixels (default 128). count above 1 builds that many puffs in one hop and returns a table of them, named name1..nameN. Stack a few with jittered position/scale/alpha and their soft edges fuse into one fluffy mass. |
Sprites.blob("name"? [, size] [, count]) |
The crisp-rim twin of Sprites.puff: the same lumpy, wobbled silhouette, but the alpha cuts off at the coast within a pixel instead of feathering — an organic shape with a sharp edge (islands, rocks, hard-edged decor that must not look fluffy). Same size/count/batch behaviour as Sprites.puff. |
Sprites("name") |
Look up a sprite by name (returns the sprite object or nil). |
Sprites.find("name") |
Same as above. |
Sprite Methods
| Method | Description |
|---|---|
sprite:load("image.png") |
Load a texture from file into this sprite. .aseprite/.ase files load every frame as an animation. .sprite files (a ZIP holding a PNG texture + a TexturePacker/Phaser/PixiJS JSON atlas) load every atlas frame as an animation frame. |
sprite:show() |
Make the sprite visible (shown by default). |
sprite:hide() |
Hide the sprite from rendering. |
sprite:move(x, y) |
Set the sprite's position. |
sprite:width() |
Return the texture width in pixels. |
sprite:height() |
Return the texture height in pixels. |
sprite:play([fps]) |
Restart and play the animation. fps overrides the per-frame timings stored in the file (0 or omitted keeps them — needed because Aseprite files often have ~1 ms delays). |
sprite:stop() |
Freeze the animation on the current frame (aliased by sprite:pause()). |
sprite:framecount() |
Number of frames in the loaded .aseprite/.sprite animation. |
Sprite Properties
| Property | Type | Description |
|---|---|---|
sprite.x |
number |
X position of the sprite's centre (the engine draws and rotates around the centre, like the physics body) (read/write). |
sprite.y |
number |
Y position of the sprite's centre (read/write). |
sprite.angle |
number |
Rotation in degrees (read/write). |
sprite.scale |
number |
Scale factor, 1.0 = original size (read/write). |
sprite.visible |
boolean |
Whether the sprite is drawn (read/write). |
sprite.frames |
number |
Number of animation frames (read-only). |
sprite.frame |
number |
Current animation frame, 0-based (read/write). |
sprite.playing |
boolean |
Whether the animation is running (read/write). |
sprite.speed |
number |
Animation speed in frames-per-second; 0 = use the file timings (read/write). |
sprite.looping |
boolean |
Whether the animation loops when it reaches the last frame (read/write). |
sprite.flipH |
boolean |
Mirror the texture horizontally (read/write). |
sprite.flipV |
boolean |
Mirror the texture vertically (read/write). |
sprite.tint |
number |
Colour multiplied over the texture, the same $AABBGGRR integer format as canvas.color / colors.*; white (colors.white) is the default (read/write). |
sprite.alpha |
number |
Opacity 0..255 folded into the tint's alpha: 0 hides the sprite without touching visible, in-between values fade it (read/write). |
sprite.layer |
number |
Draw order: every frame the sprites are drawn sorted by layer, lower layers first (behind); the default is 0, and sprites that share a layer keep a stable order by handle (read/write). |
The layer sort makes stacking deterministic, so distant decor can sit at
layer = -10, the actors at 0, and effects that must cover the action (an
explosion, a muzzle flash) at layer = 10 — no matter in which order the
sprites were created. sprite.tint and sprite.alpha are the cheap way to
flash a hit, fade a ghost or sink an object into the blue without swapping
textures.
Image files are searched in the script directory, the workspace sprites/
folder, and the current directory.
window.show(640, 480)
canvas.color(colors.black)
canvas.clear()
mysprite1 = Sprites.new("player")
mysprite1.load("richard-say.png")
mysprite1.show()
mysprite1.move(100, 200)
mysprite2 = Sprites.new()
mysprite2.load("richard-say.png")
mysprite2.move(300, 100)
mysprite2.angle = 45
mysprite2.scale = 0.5
-- Look up by name
local p = Sprites("player")
println("Sprite size: " .. p.width() .. "x" .. p.height())
local a = 0
while true do
canvas.color(colors.black)
canvas.clear()
-- Engine draws all sprites automatically; just update properties
mysprite1.move(mouse.x, mouse.y)
mysprite1.angle = a
a = a + 1
if a >= 360 then a = 0 end
sleep(16)
endTilemaps
The tiles system draws a rectangular grid of tiles from a single tileset
texture. A tilemap is created with tiles.new, loaded with load, filled with
fill/set, and drawn with draw. Both the dot form (map.set(...)) and the
colon form (map:set(...)) work.
Tile ids are 1-based: id 1 is the top-left tile of the sheet, id 12 the
last tile of the first row of a 12-column sheet, and so on. Id 0 means an
empty cell, which is skipped when drawing (so a second, empty map can be used as
an overlay layer).
Creating & Loading
| Function | Description |
|---|---|
tiles.new("name"?) |
Create a new tilemap. The optional name registers it for lookup. |
tiles.find("name") |
Look up a tilemap by name (returns the map object or nil). |
A named tilemap is also exposed as a global, so after
tiles.new("terrain") you can write terrain.set(...) directly.
Tilemap Methods
| Method | Description |
|---|---|
map.load(file[, tilew, tileh, columns, spacing, margin]) |
Load (or replace) the tileset texture. Defaults: 16, 16, 0, 0, 0. Leave columns at 0 to derive it from the texture width. |
map.resize(cols, rows) |
Resize the grid, keeping the overlapping cells. |
map.fill(tile) |
Set every cell to tile (0 clears the map). |
map.clear() |
Empty every cell. |
map.set(col, row, tile) |
Set one cell (out-of-range coordinates are ignored). |
map.get(col, row) |
Return the tile id of one cell (0 when empty or out of range). |
map.data() |
Return a flat array of tile ids, row-major (index = row * cols + col + 1). |
map.solidtile(tile [, solid]) |
Read, or set, whether a tile id is a wall. With solid it writes and returns nothing; without it returns a boolean. |
map.solid(col, row) |
true when the cell is a solid tile, or is outside the map. |
map.draw([x, y [, scale]]) |
Draw the map (enqueues one native draw command). Position defaults to the map's own x/y, scale to scale. |
draw captures a snapshot of the grid at the moment it is called, so a later
set/clear does not affect a draw already queued this frame. Re-stamp an
entity layer every frame and call draw after the changes.
Tilemap Properties
| Property | Type | Description |
|---|---|---|
map.cols |
number |
Grid width in tiles (read-only). |
map.rows |
number |
Grid height in tiles (read-only). |
map.width |
number |
Pixel width, cols * tilewidth (read-only). |
map.height |
number |
Pixel height, rows * tileheight (read-only). |
map.tilewidth |
number |
Tile width in the sheet (read-only). |
map.tileheight |
number |
Tile height in the sheet (read-only). |
map.columns |
number |
Tiles per row in the sheet (read-only). |
map.spacing |
number |
Pixels between tiles in the sheet (read-only). |
map.margin |
number |
Pixels from the sheet edge to the first tile (read-only). |
map.visible |
boolean |
Whether the map is drawn (read/write). |
map.x |
number |
Draw X position (read/write). |
map.y |
number |
Draw Y position (read/write). |
map.scale |
number |
Draw scale, 1.0 = original size (read/write). |
map.name |
string |
The map's name (read/write). |
window.show(320, 240)
-- Kenney "Tiny Dungeon": 16x16 tiles, 1px spacing, 12 columns
local map = tiles.new("ground")
map.load("tiles.png", 16, 16, 0, 1, 0) -- columns derived from the texture
map.resize(20, 15)
map.fill(49) -- 49 = a floor tile
map.solidtile(41, true) -- 41 = a wall tile
-- draw a wall border
for c = 0, map.cols - 1 do
map.set(c, 0, 41)
map.set(c, map.rows - 1, 41)
end
while true do
canvas.color(colors.black)
canvas.clear()
map.draw(0, 0, 2) -- scale 2 => 32px tiles
endSee games/dungeon/dungeon.tyro for a small game built on this API (a static
terrain map plus a second map used as an entity overlay).
3D Models
The models system holds 3D meshes that the engine draws every frame, in a
top-down orthographic pass that is pixel-aligned with the 2D canvas — a model
at x, y sits on exactly the same pixel as a sprite or a canvas.rectangle.
It is the easy way to put a real 3D object inside a 2D game. The recommended
file format is GLB (binary glTF 2.0), a single self-contained file with
mesh, texture and material.
Creating & Loading
| Function | Description |
|---|---|
models.new("name"?) |
Create a new model object. Optional name registers it for lookup. |
models.sphere("name"?) |
An engine-built unit sphere (2 wide, centred) — ready-made puffs, bubbles and planets with no file at all. |
models("name") |
Look up a model by name (returns the model object or nil). |
models.find("name") |
Same as above. |
A named model is also exposed as a global, so after models.new("jet") you
can write jet.load(...) directly. Model files are searched in the script
directory and the workspace, like sprites.
Model Methods
| Method | Description |
|---|---|
model:load("jet.glb") |
Load (or replace) the mesh. Loading happens on the engine's queue, like sprite textures; model.loaded tells when it is ready. |
model:show() |
Make the model visible (shown by default). |
model:hide() |
Hide the model from rendering. |
model:move(x, y[, z]) |
Set the screen position (pixels) and optional depth in one call. |
Model Properties
| Property | Type | Description |
|---|---|---|
model.x |
number |
Screen X of the model's centre, in canvas pixels (read/write). |
model.y |
number |
Screen Y of the model's centre, in canvas pixels (read/write). |
model.z |
number |
Depth: smaller draws in front of bigger (read/write). |
model.angle |
number |
Spin in degrees around the view axis, clockwise (read/write). |
model.roll |
number |
Bank in degrees around the model's own axis — the nose-tail line of a jet — so it leans into a turn like the classic 1942 sprite effect (read/write). |
model.tilt |
number |
Bank in degrees around the screen's horizontal axis — foreshortens the model as it leans (read/write). |
model.scale |
number |
Uniform scale in model units; combine with the bounding box below to size in pixels (read/write). |
model.visible |
boolean |
Whether the model is drawn (read/write). |
model.shadow |
boolean |
Cast a real shadow: the mesh's true silhouette projected along the sun onto the ground plane — see Light & Shadows below (read/write, default true). |
model.tint |
number |
Colour multiplied over the mesh, the same integer format as canvas.color / colors.*; white is the default (read/write). |
model.alpha |
number |
Opacity 0..255, folded into the tint's alpha like a sprite; a see-through model also casts a proportionally softer shadow (read/write). |
model.name |
string |
The model's name (read/write). |
model.width |
number |
Bounding-box X size of the loaded mesh (read-only). |
model.height |
number |
Bounding-box Y size (read-only). |
model.depth |
number |
Bounding-box Z size (read-only). |
model.loaded |
boolean |
Whether the mesh is ready (read-only). |
Models draw above the 2D canvas and sprites, below the controls, and they
shake with the screen like everything else. All three rotations (angle,
roll, tilt) turn the model about its own bounding-box centre, and the
model always faces the viewer with its own +Z pointing up the screen — so a
nose-up jet model needs no rotation for a player flying up the screen, and
angle = 180 for the enemies flying down it. With shadow on (the default)
every model drops its true silhouette on the ground plane below — see
Light & Shadows.
window.show(320, 240)
local jet = models.new("player")
jet:load("jet.glb")
-- size it by pixels: widest horizontal extent becomes 46 px
local size = math.max(jet.width, jet.depth)
jet.scale = 46 / size
jet.tint = colors.white
local t = 0
function update(dt)
t = t + dt
jet:move(160 + 90 * math.sin(t), 150, 0)
jet.roll = 30 * math.sin(t * 2) -- bank left/right into the turn
endgames/jet2026/jet2026.tyro is a 1942-style scrolling shooter built this
way: one GLB jet reused through a pool for the player and every enemy,
tinted and rotated per role. Its clouds are stacks of Sprites.puff
sprites (one batch per cloud), their soft edges fusing into fluff, and the
sea is a live one: sea.frag rolls swells, sun glitter and drifting crests
over the board's blue pixels, masked so islands and bullets keep their
sharp shapes.
Light & Shadows
Cast shadows share one sun, and it lives on the world table. Every
function below reads when called with no arguments and sets (then returns)
when called with them.
| Function | Description |
|---|---|
world.light([dx, dy, dz]) |
The direction the sunlight travels (dy may not be 0). The default (0.06, 1, -0.035) sits the sun high behind the viewer, so shadows slide a little right and down. |
world.ambient([v]) |
How much sky light fills the shadows, 0..1 (clamped). 0 is a hard sun with the darkest shadows, 1 is all ambient light so nothing stays dark. Default 0.45. |
world.ground([z]) |
The model.z plane shadows fall on — the "floor" (the sea, say), which must sit farther from the eye than the models flying over it. Default 300. |
A shadow is the mesh projected along the light ray onto that plane, so it is the model's true silhouette — wings, bank and all — and it slides farther away the closer the model sits to the viewer. Models nearer the eye than the ground plane paint over the shadows behind them, so a 3D cloud between the viewer and the sea covers the shadow under it and never catches one on top.
world.light(0.10, 1, -0.05) -- shadows land to the right and below
world.ambient(0.45) -- soft darkness, sky light fills the rest
world.ground(300) -- the sea plane, far behind the jetsShader Effects
Post-processing screen shaders are controlled with the shader table. They are
built-in and need no files. Assign properties the usual way:
shader.effect = "water" -- pick the effect
shader.value = 0.5 -- 0..1 effect parameter
shader.area = {x, y, w, h} -- restrict the effect to a rectangle| Property | Description |
|---|---|
shader.effect |
"water", "glow", "gray", "sepia", "invert", "vignette", "pixelate" or "none" |
shader.value |
0..1 effect parameter (see per-effect meaning below) |
shader.area |
Rectangle {x, y, w, h} in canvas pixels (y from the top) where the effect applies; outside it the canvas is unchanged |
shader.load(file) |
Load a custom fragment shader from a GLSL file and activate it (see below) |
| Effect | What it does | value meaning (default) |
|---|---|---|
"water" |
Wavy water with foam across the lower region | water surface height (0.34) |
"glow" |
Soft glow around bright pixels (suns, bulbs, lasers) | height of the glowing region from the bottom (1.0 = full) |
"gray" |
Grayscale | gray strength (1.0) |
"sepia" |
Old-photo look | sepia strength (1.0) |
"invert" |
Inverted colors | inversion strength (1.0) |
"vignette" |
Darkened corners | vignette amount (0.5) |
"pixelate" |
Chunky pixel blocks | block size (0.06 ~ 1px) |
window.show(640, 480)
shader.effect = "glow" -- turn it on
shader.value = 0.5 -- glow on the bottom half
-- draw something bright, it will glow
canvas.color = colors.yellow
canvas.circle(320, 240, 60, true)shader.value and shader.area survive effect switches, so once set they apply
to whichever effect is active. Toggle effects and tweak the parameters at any
time:
if keyboard.pressed("1") then shader.effect = "water" end
if keyboard.pressed("2") then shader.effect = "glow" end
if keyboard.pressed("0") then shader.effect = "none" end
if keyboard.pressed("q") then shader.value = shader.value - 0.1 end
if keyboard.pressed("e") then shader.value = shader.value + 0.1 end
if keyboard.pressed("z") then shader.area = {0, canvas.height/2, canvas.width, canvas.height/2} endThe "water" effect tints and distorts the lower part of the canvas, so draw a
shore or sea bottom there if you want a sea scene. See demos/shader_demo.tyro
for a full on-screen demo of every effect, value and area.
Custom shaders
shader.load("file.frag") loads a fragment shader from a file and activates
it. The file is looked up next to the script, then in the current directory, then
in assets/ under the workspace. It must be GLSL #version 330 with the same
outputs as the built-in effects:
#version 330
uniform vec2 resolution; // canvas size in pixels
uniform float time; // elapsed seconds
uniform float value; // shader.value (0..1)
uniform vec4 area; // shader.area {x, y, w, h} (y from the bottom)
uniform sampler2D texture0; // the canvas
uniform vec4 colDiffuse; // vertex color
in vec2 fragTexCoord;
in vec4 fragColor;
out vec4 finalColor;All uniforms are optional: use the ones you need and ignore the rest. shader.value
and shader.area still control value/area. Set shader.effect = "none" to go
back to the built-in effects. See demos/custom.frag for a working example:
shader.load("custom.frag") -- activate the scanlines shader
shader.value = 0.8
shader.area = {0, 0, canvas.width, canvas.height}Screen Shake
shake jolts the world, the way an accident or an error should feel. Every
frame the canvas and the sprites are moved by a random offset that fades out
until the time is up; the terminal and the other controls stay glued to the
window.
| Function | Description |
|---|---|
shake(ms [, power]) |
Shake the world for ms milliseconds; power is the maximum offset in pixels (10 by default) |
window.shake(ms [, power]) |
The same function on the window table |
window.shaking |
Read-only boolean — true while a shake is still running |
shake(0) |
Stop a running shake |
A new call restarts the shake, so a longer or harder one simply wins. See
demos/shake_demo.tyro.
window.show(640, 480)
-- an accident: a long, hard jolt
shake(800, 40)
function update(dt)
if keyboard.pressed("space") then
window.shake(400) -- 400ms with the default power
end
end
function draw()
canvas.color = colors.black
canvas.rectangle(0, 0, canvas.width, canvas.height, true)
canvas.color = colors.red
canvas.circle(300, 240, 30, true)
endControls
Controls are created by class name and are owned by the control tree. Panels are
containers, so a control can be nested inside another control with
controls.parent.
panel = controls.new("panel", "", 0, 0, 240, 300)
controls.align(panel, "left")
top = controls.new("button", "Top", 0, 0, 180, 40)
controls.parent(top, panel)
controls.align(top, "top")
bottom = controls.new("button", "Bottom", 0, 0, 180, 40)
controls.parent(bottom, panel)
controls.align(bottom, "bottom")A handle is also a property bag, so every row above that reads or writes a value has a field spelling on the handle itself. The field is the value - never a function - so these are interchangeable:
local b = controls.new("button", "Hi", 10, 10, 120, 32)
b.caption = "Bye" -- controls.caption(b, "Bye")
b.width = 200 -- controls.width(b, 200)
b.align = "left" -- controls.align(b, "left")
b.visible = false -- controls.visible(b, false)
b.parent = panel -- controls.parent(b, panel)
local w, h = b.width, b.height| Field | Type | Notes |
|---|---|---|
x / left, y / top, width, height |
integer | Preferred geometry; writing one of them keeps the others |
visible, name, border, backcolor, align, parent |
Same values as the functions above | |
text / caption |
string | The caption of a button, label, or checkbox; the edited text of an edit |
hover, down, clicked, focused |
boolean | Read-only mouse and keyboard state |
checked |
boolean | A checkbox only, false on anything else |
items, itemindex, viewcount |
integer | A listbox only; lst.items = {"a", "b"} replaces the list, a single string replaces it with one item and nil empties it |
loaded |
boolean | An image only; img.file = "logo.png" loads the texture |
The names that name an action stay methods on the handle and take a colon call,
because a Lua key cannot be both a value and a function:
lst:additem("a"), lst:clear(), lst:item(0), b:show(), b:hide(),
b:move(x, y), b:position(x, y), b:focus(), img:load("logo.png"). Each one
also takes the handle as its first argument on the table: controls.additem(lst, "a").
| Function | Description |
|---|---|
controls.new(class, name, x, y, w, h) |
Create a button, panel, label, checkbox, edit, spectrum, listbox, or image; returns a handle. If name is a number, the name is omitted and coordinates follow. |
controls.align(handle [, value]) |
Get/set none, left, top, right, bottom, or client |
controls.parent(handle [, parentHandle]) |
Get/set the container; the getter returns 0 for the main window, and nil/0 moves the control there |
controls.width/height(handle [, value]) |
Get/set the preferred size |
controls.position/move(handle, x, y) |
Get/set the preferred position |
controls.visible/show/hide(handle) |
Show or hide a control; hidden aligned controls release their space |
controls.hover/down/clicked(handle) |
Read mouse state |
controls.border(handle [, style]) |
0 none, 1 thin, 2 thick, 3 sizable |
controls.backcolor(handle [, color]) |
Set a color from the colors table |
BoundsRect stores a control's preferred position and size. Docking changes only
its effective WindowRect, so controls.width, controls.height, and
controls.position keep reporting the preferred geometry. Changing a child's
size realigns its siblings, and resizing the main window propagates through
every container level. See demos/controls_align.tyro for a left-docked panel
with top- and bottom-docked buttons.
Image controls
image is a control that shows a texture. Its caption argument is the file to
show, so the common case is one line:
logo = controls.new("image", "logo.png", 40, 40, 128, 128)
controls.name(logo, "logo")The texture is loaded on the main thread, and a texture larger than the requested
size grows the control. The file is looked up like every other script asset
(work path, then assets, then the app folder).
An existing control can load another file at any time, and reports whether it holds a texture:
controls.load(logo, "other.png") -- or logo.load("other.png"), or logo.file = "other.png"
if not logo.loaded then
print("logo.png could not be read")
end
logo.load("") -- release the texturecontrols.load answers true/false for the file it just read, and false
for a control that is not an image.
Timing
| Function | Returns | Description |
|---|---|---|
sleep(ms) |
— | Pause the script thread for ms milliseconds |
pause(ms) |
— | Like sleep(ms), but also freeze the scene while the script waits: physics, sprite animation/on_update and controls stop advancing, so the frame the script queued stays on screen. Audio (music/radio) and input keep running. Use it for a still hold; use sleep() when the scene should keep moving |
enter() |
— | Hold the processing queue: the drawing commands queued after this call are not executed until leave() balances it |
leave() |
— | Release the hold taken by enter(): the whole group queued in between is then drawn at one drawing frame |
frametime() |
number (seconds) |
Time elapsed since the last frame |
time() |
number (seconds) |
Elapsed time since the window was created |
millis() |
number (ms) |
High-resolution monotonic clock in milliseconds (the same timer the frame budget uses); fine enough to time a drawing body |
rand(min, max) |
integer |
Random integer in [min, max] |
Game loop: update(dt) / draw()
Define update and/or draw as globals and let the top-level chunk return.
The engine then drives the frame for you:
window.show(640, 480)
local t = 0
function update(dt) -- once per frame, before drawing; dt in seconds
t = t + dt
if keyboard.pressed("space") then exit() end
end
function draw() -- once per frame, after update()
canvas.color = colors.black
canvas.rectangle(0, 0, canvas.width, canvas.height, true)
canvas.color = colors.green
canvas.circle(320 + 100 * math.sin(t), 240, 20, true)
end -- chunk returns: the engine drives from hereBoth callbacks are optional. Once the top-level chunk returns normally, the
engine looks for them; if either exists it keeps the script (and its locals,
which stay live inside the callbacks as upvalues) and calls update(dt) then
draw() once per frame on the main thread — no sleep() pacing needed.
exit() inside update ends the run. The callbacks must be globals:
local function update(dt) is not found by the engine.
If the chunk never returns (for example it keeps a while true do loop), or
defines neither callback, the script stays on its worker thread and can still
draw and pace itself with sleep():
window.show()
start = time()
while true do
ft = frametime()
canvas.text(10, 10, "frame: " .. ft)
sleep(16) -- ~60 FPS pacing
end
print("uptime: " .. (time() - start))Because only the engine callbacks touch the state after the hand-off — the loader thread stops before the engine takes over — the two models are interchangeable and never race. See docs/engine-loop.md for the details.
Use pause(ms) instead of sleep(ms) for a still hold: it also freezes the
scene (physics, sprites, controls) for the duration while music/radio keep
playing.
canvas.text(10, 10, "Paused...")
pause(1000) -- the text stays on screen, the scene is still
canvas.text(10, 30, "Back!")Grouping drawing with enter() / leave()
enter() locks the processing queue: the drawing commands the script queues
after the call keep piling up but are not executed by the main loop. leave()
releases the lock, and the whole group is then drawn at one drawing frame —
instead of a large batch being split over several frames. Commands queued
outside the group behave as usual. Use it to group a batch inside update /
draw:
window.show()
function draw()
canvas.rectangle(0, 0, canvas.width, canvas.height, true)
enter() -- hold the queue
for i = 0, 99 do -- this batch is drawn as one
canvas.circle(rand(0, canvas.width), rand(0, canvas.height), 4, true)
end
leave() -- draw the whole group now
canvas.text(10, 10, "frame: " .. time())
endenter()/leave() nest (they count up and down), and a script that stops or
errors between them is released automatically so the next run never inherits a
frozen queue. See docs/atomic.md for the commit-watermark
details.
Measuring the queue (queue.*)
The engine exposes read-only counters to help diagnose the "black box, then the text" flicker, where a frame is presented with only part of a drawing body.
These counters are diagnostics only, and are compiled only in debug builds (the
diagnose conditional tracks the debug switch). A release build reports 0 for
the diagnostic-only ones; queue.consumed, queue.depth, queue.holds and
queue.limit always stay live.
The same fields can be watched live, without a script, by pressing CTRL+F10:
the engine draws a compact panel in the window's top-left corner, showing every
counter below (plus the current fps) and refreshed each frame. Press it again
to hide the panel. Only the counters above the diagnose line are populated in
a release build.
| Field | Description |
|---|---|
queue.consumed |
Drawing commands executed since the engine started (grows on every drain) |
queue.passes |
ProcessQueue passes that executed at least one command |
queue.deferred |
Passes stopped at the frame time budget while more commands were still queued (each one drew a partial batch) |
queue.depth |
Commands waiting in the queue right now |
queue.max |
Largest backlog seen at the start of a pass (a high-water mark) |
queue.holds |
Open enter() calls (the hold depth) |
queue.frames |
Drawing frames presented since the engine started |
queue.drainms |
Duration of the most recent drain pass, in milliseconds |
queue.lastgap |
Milliseconds between the last two queued commands (no yield in between) |
queue.maxgap |
Largest such gap of the run: the script stalled between two queue calls |
Because the script builds a body while the main loop drains the queue on its own thread, a frame boundary can fall in the middle of a body. The script can catch it right after the body returns:
function draw()
local before = queue.consumed -- queue is empty here
enter() -- or queue the body bare
n = draw_body()
leave()
local mid = queue.consumed - before -- drained while the body was built
-- 0 < mid < n -> a frame showed only part of the body (the flicker)
endThe same trick with millis() times how long the body takes to queue:
local t0 = millis()
n = draw_body()
print("body queued in " .. (millis() - t0) .. " ms")Grouping makes the body land in one drawing frame, but it cannot fix the script
itself taking a long time between two commands (a radio.station getter that
blocks on a lock the network thread holds, file I/O, ...). The engine stamps
every queued command with its enqueue time, so queue.maxgap reports the
largest time the script spent between two consecutive queue calls with no yield
in between — exactly the gap between a rectangle() and the text() that is
meant to cover it:
canvas.rectangle(0, 0, 640, 70, true) -- opaque black box
-- radio.station blocks here for a second. The queue is held, so nothing is
-- drawn yet and the frame freezes instead of showing a partial body.
canvas.text(8, 8, "Station : " .. radio.station)
print("stall between box and text: " .. queue.maxgap .. " ms")Wait times that are yields (sleep(), pause(), console.read()) reset the
baseline, and so does the start of each engine-driven frame, so maxgap does not
count frame waits: a small maxgap with a black-only header means the metadata
itself is empty (buffering/reconnect), not a rendering race.
To be told when a stall happens, watch the gap from the script (used by
demos/radio.tyro). The first gap that clears the limit is printed to the
console; a new report needs a gap a full limit above the last one reported, so
drift never spams:
local gap_warned = 0
local function gapwatch(aLimitMS)
local g = queue.maxgap
if g > aLimitMS and g >= gap_warned + aLimitMS then
gap_warned = g
println("GAP: " .. g .. " ms between two queued commands"
.. " (limit " .. aLimitMS .. " ms, lastgap " .. queue.lastgap .. " ms)")
end
return g
end
function draw()
-- ...draw the body...
gapwatch(250) -- >250 ms stall? the console says so
endWrapping the body in enter()/leave() holds the queue, so the main loop
cannot drain mid-body and no partial frame can appear.
Examples
See demos/README.md for the full demo index. Run any demo with:
tyro demos/<name>.tyro| File | Feature |
|---|---|
demos/pong.tyro |
Complete Pong game — drawing, keyboard input, AI, physics, collision, sound, scoring |
demos/basic_drawing.tyro |
All drawing primitives: rectangle, circle, line, point, text, colors |
demos/animated_demo.tyro |
Animation loop with random colors and sleep timing |
demos/bounce_demo.tyro |
Engine-driven update(dt) / draw() loop — a ball moves once per frame |
demos/enter_leave_demo.tyro |
Grouped drawing with enter() / leave() — a batch of draw calls lands in one drawing frame |
demos/sprites_demo.tyro |
Sprites system: load, show, hide, move, rotate, scale, named access |
demos/controls.tyro |
Generic buttons, labels, checkboxes, edits, and panels |
demos/controls_align.tyro |
Nested controls: a left-docked panel with top- and bottom-docked buttons |
demos/aseprites_demo.tyro |
Aseprite animations: load .aseprite files (all frames as textures), play(fps), stop, looping, per-frame stepping — idle/walk/run showcase |
demos/sprite_atlas_demo.tyro |
Texture atlas .sprite files: load a ZIP'd PNG + JSON atlas (TexturePacker/Phaser/PixiJS), every atlas frame becomes an animation frame, play/stop, speed control |
demos/interactive_paint.tyro |
Mouse drawing with keyboard color switching (uses input APIs) |
demos/console_demo.tyro |
Console output: print, println, log |
demos/terminal_demo.tyro |
Built-in terminal commands: dir, list, clear, help, exit |
demos/console_read_demo.tyro |
Interactive console.read() — prompt the user for input from Lua |
demos/music_demo.tyro |
Sound effects (music.sound, music.noise) and MML melodies (music.mml) |
demos/test.tyro |
Circle animation with random colors |
demos/colors_bar.tyro |
Full color palette display |
demos/multiply.tyro |
Drawing + MML sound |
demos/text.tyro |
Multi-language text rendering |
demos/shader_demo.tyro |
Post-processing shaders: water, glow, gray, sepia, invert, vignette, pixelate |
demos/shake_demo.tyro |
Screen shake: shake(ms, power) / window.shake(ms, power) — the world jolts like an accident or an error |
Threading model & lifecycle
Script source runs on a worker thread, never on the render/main thread.
Every RayLib call and every control/console/audio mutation a script makes is
packaged as a queue object (TQueueObject) and dispatched back to the main
thread, where the main loop drains the queue (Main.Queue) once per frame.
- Main-thread confinement. Window, canvas, sprites, shaders, fonts, music/
sound (the audio device), radio, spectrum and the control tree are all
owned by the main thread and must only be touched there. The Lua facades hide this:
they enqueue
TCreateControlObject/TSetControl*Object/draw/music objects that execute on the main thread. - Worker lifecycle.
Startpublishes an atomicstartedflag,Stop(also triggered by--exit/window close) cancels a pending queue wait, signals blockedconsole.read(), then pumpsCheckSynchronizeuntil the thread has actually finished beforeWaitForand disposal. - Frame hand-off (
update/draw). When the main chunk returns normally and its globals defineupdateand/ordraw, the finished worker'slua_Stateis kept alive and those callbacks are driven once per frame on the main thread; only the main thread touches the state from then on. Otherwise — no callbacks, or a chunk that never returns — the worker closes the state as usual. A laterStop/new run releases a handed-off state (see docs/engine-loop.md). - Queue admission. A stopped/inactive script rejects new asynchronous work
(the request is freed immediately instead). Right after the worker exits the
engine cancels and clears any stale queue entries so they cannot leak into a
later interactive run; under
--exita final drain runs first so work the worker accepted before completing is still honored. - Per-state Lua cancellation. Termination status lives in each Lua state's extra space, so cancelling one script can never affect another state.
- Loaded script vs. worker.
load/F4fill a template script that owns the source; every run (run,F5, closing the editor) stops the current worker, clones that template and starts a new worker on the clone. The worker owns and frees its clone, so the template — and the editor buffer saved into it — survives a stop and is never written by a running script. - Clean slate per run. Before the clone starts, the run frees the controls the
finished run left in the window and the sprites it left in the sprite store
(physics bodies first, since they are keyed by sprite handle), then wipes both
canvases. The engine controls (console, output, editor, file picker) are kept,
and keyboard focus and the mouse capture are dropped while the control they point
at is still alive. Canvas clearing happens inside
BeginDraw/EndDraw, which is the only window in which a texture canvas is bound to its render texture. - Resource release order. GPU/audio owners (sprites, canvases, shaders, fonts, musicians, generated waveforms, radio, spectrum) are destroyed while the window/OpenGL context and audio device are still alive; the context and device are closed last, after every owner has been released.
The worker thread reads current input/timing values (keyboard.down, mouse.x,
frametime, ...) and submits drawing/mutation requests through the queue; it
must not hold references into the control tree or RayLib objects across
calls. Scripts blocking on console.read() or sleep() are cancelled by
Stop, so a shut-down never waits on user input.
Issues
There is problem in raylib, in fact in OpenGL that cannot/not easy share texture between threads, we need used a trick to pass drawing commands to main thread
https://github.com/raysan5/raylib/issues/454
Compile
Lazarus or Delphi
Libraries
minilib MiniLib package minilib.lpk
miniLua MiniLua package minilua.lpk
Dependencies
raylib for raylib.dll/so put it in same of tyro exe folder
Competition
Fonts
Thanks for
https://github.com/IT-Studio-Rech/bdf-fonts
https://forums.adafruit.com/viewtopic.php?t=203655
Jet Plan https://sketchfab.com/3d-models/jet-88a540bdacd940648223f517b9af9452

