Introduction§
Teal++ is a superset of Teal, a statically-typed dialect of Lua, combined with TGK (Teal++ GameKit) — a game development framework built on LÖVE.
The pitch is simple: write a game in one .tpp file, run one command, get a game. Teal++ compiles .tpp → .tl (Teal) → .lua, and TGK handles the boilerplate that every LÖVE game ends up rewriting.
What you get§
- A language — Teal with Kotlin-style data classes, imports, and class definitions.
- A framework — 11 modules covering graphics, audio, assets, input, timers, scenes, GUI, cameras, windows, docking, and error reporting.
- A packaging pipeline —
tpp bundleandtpp packagefor Linux, Windows, macOS, and the web. - A self-contained installer — 1.4 MB Linux AppImage with Lua 5.4 and the
tlcompiler bundled. - Type safety — every TGK module ships with Teal declarations;
tl checkcatches errors before the game runs.
Architecture§
main.tpp
│
│ tpp build / tpp love
▼
main.tl ← Teal source (type-checked)
│
│ tl check (type check)
│ tl gen (code generation)
▼
main.lua ← plain Lua 5.4
│
│ love .
▼
LÖVE runtime ← window, graphics, audio, input
Teal++ is a transpiler, not an interpreter. The output is ordinary Lua that LÖVE runs directly. This means:
- You can inspect every generated
.luafile. - You can hand-edit the Lua if you need to (though you shouldn't — edit the
.tppinstead). - Teal's type checker runs on the intermediate
.tl, so type errors surface before runtime.
Who this is for§
Teal++ is for people who:
- Like Lua's simplicity but want static types.
- Want to ship a LÖVE game without rewriting the same lifecycle boilerplate every time.
- Need a packaging pipeline that produces distributable artifacts for multiple platforms.
- Are comfortable with a
.tpp→.luaworkflow.
Teal++ is not a replacement for Teal. It's a thin layer on top that adds import … as …, class/data-class syntax, and TGK's runtime. Any valid Teal code is valid Teal++ (in program mode), but not vice versa.
Credits§
Teal++ stands on the shoulders of three projects:
Installation§
There are three ways to install Teal++ depending on your platform and how much you want bundled.
Linux — one file§
The fastest path. Download the AppImage, make it executable, run it. Everything — Lua 5.4, the tl compiler, TGK, and the docs — is inside.
wget https://github.com/Sheldevv/tealpp/releases/latest/download/tealpp-latest-x86_64.AppImage
chmod +x tealpp-latest-x86_64.AppImage
./tealpp-latest-x86_64.AppImage love my_game.tpp
The AppImage is 1.4 MB and self-contained. It works on Bazzite, Fedora, Ubuntu, Arch, Debian, NixOS — any Linux with a compatible kernel and glibc.
The AppImage includes the toolchain (Lua, tl, TGK) but not LÖVE itself. Install LÖVE 11+ separately from love2d.org if you want to run games locally. Packaged games (tpp package --target linux --love-bin love) can bundle LÖVE if you pass the binary.
Linux / macOS — from source§
If you prefer a source install (or you're on macOS), clone the repo and run the installer:
git clone https://github.com/Sheldevv/tealpp.git
cd tealpp
./install.sh
The installer:
- Clones the repo into a temporary directory.
- Copies
bin/,src/,gamekit/,gamekit.d.tl, and the docs into~/.local/tpp. - Writes a
tppwrapper to~/.local/bin/tpp. - Prints the exact
export PATH=…line you need if~/.local/binisn't on yourPATH.
Edit the config block at the top of install.sh to change the repo URL, branch, or install directory. Every variable can also be overridden via environment:
TPP_GIT_REPO=https://github.com/Sheldevv/tealpp.git ./install.sh
TPP_INSTALL_DIR=/usr/local sudo -E ./install.sh
Windows§
Two options:
PowerShell installer
Set-ExecutionPolicy -Scope Process Bypass -Force
.\install.ps1
Installs to %LOCALAPPDATA%\tpp and places tpp.cmd in %LOCALAPPDATA%\Microsoft\WindowsApps, which is on PATH by default on Windows 10+.
Runtime only (for players)
Players don't need Teal++ installed at all. Ship them a fused .exe produced by tpp package --target windows --love-bin <dir-with-love.exe>. They double-click and play — no dependencies, no install.
Requirements§
| Tool | Needed for | Notes |
|---|---|---|
lua | Running the CLI | Bundled in the AppImage. On source installs, install Lua 5.4. |
tl | tl check / tl gen | Bundled in the AppImage. On source installs: luarocks install tl. |
love | tpp love, playing games | Not bundled. Install LÖVE 11+ separately. |
zip | tpp bundle / tpp package | Usually preinstalled. apt install zip if missing. |
unzip | tpp package --target web | Usually preinstalled. |
git | Installer only | Not needed at runtime. |
Verifying the install§
tpp --help
# Should print the Teal++ usage banner
tpp test
# Runs the full test suite — 63 assertions + two packaging smoke tests
Hello, World§
The smallest possible Teal++ game. Save this as hello.tpp:
import gamekit as GameKit
GameKit.app({
title = "hello",
width = 640,
height = 480,
})
function draw()
GameKit.graphics.clear("#0b0d18")
local w, h = GameKit.graphics.getDimensions()
GameKit.graphics.circle("fill", w / 2, h / 2, 48, "#15f5ff")
end
Run it:
tpp love hello.tpp
You should see a dark window with a cyan circle in the centre.
What just happened§
tpp lovedetected theimport … as …line and chose program mode.compile_programlowered the import tolocal GameKit = require("gamekit").tl checktype-checked the result againstgamekit.d.tl.tl genproducedhello.lua.love .ran the game.GameKit.appwrapped LÖVE's lifecycle and dispatcheddraw()every frame.
Adding an animation§
GameKit.app dispatches update(dt) if you define it. Use dt to move things at frame-rate-independent speeds:
import gamekit as GameKit
local t: number = 0.0
GameKit.app({
title = "hello",
width = 640,
height = 480,
})
function update(dt: number)
t = t + dt
end
function draw()
GameKit.graphics.clear("#0b0d18")
local w, h = GameKit.graphics.getDimensions()
local r = 48 + math.sin(t * 2) * 12
GameKit.graphics.circle("fill", w / 2, h / 2, r, "#15f5ff")
end
The circle now pulses. That's the entire ceremony for a LÖVE game with Teal++.
Notice local t: number = 0.0 — not 0. In Teal, 0 is inferred as integer, and t = t + dt (where dt is number) would fail. Use 0.0 for any float accumulator.
Program Mode§
Program mode is the default for any .tpp file that contains an import … as … line. It produces a complete, runnable Lua program.
What program mode does§
compile_program (in src/tpp.lua) processes each line:
import foo.bar as Baz→local Baz = require("foo.bar")data class … endblocks → transpiled inline to Teal record + methods- Everything else → passed through verbatim to Teal
The output is a .tl file that Teal can type-check and compile to .lua.
Mode detection§
The CLI picks the mode automatically:
| File contains… | Mode |
|---|---|
import … as … | program |
data class … at top level | program |
Only class X … end | class |
| Neither | class (default) |
Override with --program or --class:
tpp build my_file.tpp --program
tpp build my_file.tpp --class
Full example§
import gamekit as GameKit
data class Point(x: number, y: number)
end
local player = Point.new(100, 100)
GameKit.Input.bind("right", "d", "right")
GameKit.Input.bind("left", "a", "left")
GameKit.Input.bind("up", "w", "up")
GameKit.Input.bind("down", "s", "down")
GameKit.app({
title = "Program mode",
width = 800,
height = 600,
})
function update(dt: number)
local x = player.x
local y = player.y
if GameKit.Input.down("right") then x = x + 200 * dt end
if GameKit.Input.down("left") then x = x - 200 * dt end
if GameKit.Input.down("up") then y = y - 200 * dt end
if GameKit.Input.down("down") then y = y + 200 * dt end
player = Point.new(x, y)
end
function draw()
GameKit.graphics.clear("#0d1117")
GameKit.graphics.circle("fill", player.x, player.y, 20, "#22d3ee")
end
This file has an import (program mode), a data class (transpiled inline), and normal top-level code. It compiles and runs as one unit.
class Foo … end (non-data) is still class-mode-only. If you want a class definition and game code in the same file, use a data class or split into two files (one class, one program that requires it).
Class Mode§
Class mode compiles a single class (or data class) definition to a Teal record + constructor + methods. The output is a Lua module you can require from other files.
Class mode is the default for files that don't contain an import and don't contain a data class.
Syntax§
class Color
public r: number
public g: number
public b: number
constructor(r: number, g: number, b: number)
self.r = r
self.g = g
self.b = b
end
public add(other: Color): Color
return Color.new(
self.r + other.r,
self.g + other.g,
self.b + other.b
)
end
public __add(other: Color): Color
return self:add(other)
end
end
Features§
| Feature | Syntax |
|---|---|
| Fields | public name: type / private name: type |
| Constructor | constructor(params) |
| Auto-assignment | constructor(@x: number) — @ means self.x = x |
| Methods | public name(params): return_type |
| Metamethods | public __add(other: Color): Color |
Compiling a class§
tpp build src/color.tpp
# → src/color.tl
# → src/color.lua
# Or explicitly:
tpp build src/color.tpp --class
The generated .lua is a self-contained module:
local Color = {}
local Color_mt = { __index = Color, __add = ... }
function Color.new(r, g, b) ... end
function Color:add(other) ... end
return Color
Use it from another file with require:
local Color = require("color")
local c = Color.new(255, 0, 255)
Limits§
- One class per file. Two
classdeclarations in the same file produce an error. - No top-level code. Class mode expects the whole file to be a class definition. Put test code in a separate program-mode file.
- No inheritance. Teal records don't support
extends. Use composition (fields containing other class instances).
Data Classes new§
Data classes are a Kotlin-inspired feature: you declare the fields once, and Teal++ generates the constructor, __tostring, __eq, and copy for you.
Syntax§
data class Point(x: number, y: number)
public dist(other: Point): number
local dx = self.x - other.x
local dy = self.y - other.y
return math.sqrt(dx * dx + dy * dy)
end
end
That single declaration produces:
Point.new(x, y)— constructorself.x,self.y— auto-assigned fields__tostring→"Point(x=3, y=4)"__eq→ structural equality (a.x == b.x and a.y == b.y)copy()→Point.new(self.x, self.y)- Any methods you wrote in the body
Usage§
local p1 = Point.new(3, 4)
local p2 = Point.new(3, 4)
local origin = Point.new(0, 0)
print(p1) -- Point(x=3, y=4)
print(p1 == p2) -- true (structural)
print(p1 == origin) -- false
print(p1:dist(origin)) -- 5.0
print(p1:copy() == p1) -- true
Overriding generated methods§
If you define __tostring, __eq, or copy in the body, Teal++ uses yours instead of the generated one:
data class Version(major: number, minor: number)
public __tostring(): string
return "v" .. tostring(self.major) .. "." .. tostring(self.minor)
end
-- __eq and copy are still auto-generated
end
Works in both modes§
Data classes work in class mode (as a module) and program mode (inline in a program). In program mode, compile_program extracts the block, runs it through the class compiler, strips the module boilerplate, and inlines the result.
Limits§
| Feature | Status |
|---|---|
| Auto fields | ✅ |
| Auto constructor | ✅ |
Auto __tostring | ✅ |
Auto __eq (shallow) | ✅ |
Auto copy() (no args) | ✅ |
| Override any of the above | ✅ |
copy(x = 5) (Kotlin-style) | ❌ — Teal has no optional record fields |
| Deep equality | ❌ — fields compared with == |
| Destructuring | ❌ — no Lua equivalent |
hashCode | ❌ — use tostring(p) as a table key |
Vec2, Vec3, Color, Rect, Version, and config value objects are the natural fits. Anywhere you'd otherwise write a constructor + field assignments + an equality helper by hand.
Imports§
Teal++ adds a single line-syntax for imports:
import gamekit as GameKit
import gamekit.camera as Camera
These lower to plain require calls:
local GameKit = require("gamekit")
local Camera = require("gamekit.camera")
Rules§
- Module paths use dots:
foo.bar.baz→require("foo.bar.baz"). - The alias is required.
import foowithoutasis an error. - No relative paths, no leading/trailing dots.
- Comments after imports are stripped before parsing.
How it works§
compile_program scans line-by-line. Every line matching ^import … as … becomes a local declaration. All other lines pass through unchanged. This is why program mode preserves Teal syntax — Teal++ only touches the import lines and data-class blocks.
Importing TGK§
The TGK bundle is staged into your project directory the first time you run tpp love or tpp run:
tpp love examples/showcase/main.tpp
# cp '…/gamekit.d.tl' 'examples/showcase/gamekit.d.tl'
# cp -R '…/gamekit' 'examples/showcase/gamekit'
The staged gamekit/ and gamekit.d.tl let Teal resolve require("gamekit") and type-check against the declarations. Existing copies are not overwritten — if you edit gamekit/, delete the staged copy first:
rm -rf examples/my_game/gamekit examples/my_game/gamekit.d.tl
Type Annotations§
Teal++ inherits Teal's type system. Every parameter, field, and local can carry a type annotation, and tl check verifies them before your game runs.
Basic types§
local count: number = 10
local name: string = "player"
local active: boolean = true
local items: {string} = { "sword", "shield" }
local cache: {string: number} = { score = 0 }
Records§
local record Player
x: number
y: number
health: number
name: string
end
local p: Player = { x = 0, y = 0, health = 100, name = "hero" }
Function types§
local callback: function(number): string
local handler: function(string, number?) -- second arg optional
Union types§
local target: Enemy | nil = nil
local value: string | number = "hello"
Narrowing (and its limits)§
Teal narrows unions after if not x then return end, but the compiler versions vary in how aggressively they do it. If a narrowed union still errors when you access a field, cast explicitly:
local target: Enemy | nil = nearest_enemy()
if not target then return end
-- If Teal refuses to narrow, cast:
local enemy = target as Enemy
print(enemy.x, enemy.y)
Compiler quirks§
| Issue | Workaround |
|---|---|
Optional fields in records (field?: type) fail to parse | Use any for config-style records; validate at runtime |
local n = 0 infers integer; adding dt fails | Use local n = 0.0 |
Union not narrowed after nil guard | Cast with x as T after the guard |
CLI Overview§
Every command shares the same option surface:
| Option | Meaning |
|---|---|
-o, --output <path> | Output .tl path (build/check/run/love); output directory (bundle/package) |
--program | Force program mode |
--class | Force class mode |
--no-check | Skip tl check during build |
--name <name> | Artifact name (default: source filename stem) |
--target <os> | linux | windows | macos | web |
--love-bin <path> | LÖVE binary (linux), directory (windows), love.app (macos), or love.js.zip (web) |
-h, --help | Show help |
Command summary§
| Command | Produces |
|---|---|
tpp build <file> | .tl + .lua |
tpp check <file> | type-check only, no output |
tpp run <file> | compile + run with lua |
tpp love <file> | compile + run with love |
tpp bundle <file> | build/<name>/, build/<name>.love, launcher script |
tpp package <file> --target <os> | distributable artifact |
tpp test | runs the full test suite |
tpp uninstall | removes the source install |
build / check / run§
tpp build§
Compiles .tpp → .tl, runs tl check, then tl gen → .lua.
tpp build examples/showcase/main.tpp
# Generated examples/showcase/main.tl (program mode)
# Type checked main.tl — 0 errors
# Wrote: main.lua
Add --no-check to skip the type-check step (useful when iterating fast on a file with known warnings):
tpp build main.tpp --no-check
tpp check§
Type-check only. No .lua output. Faster than build when you just want to confirm types:
tpp check main.tpp
# Check passed: main.tl
tpp run§
Compiles and runs the resulting Lua with the system lua. Useful for non-LÖVE tools, tests, or scripts:
tpp run examples/dataclass/main.tpp
# Point(x=3, y=4)
# true
# false
# 5.0
# …
love§
Compiles and launches a LÖVE game. The dev-loop command.
tpp love examples/showcase/main.tpp
What it does:
- Detects program mode (because of the
import). - Lowers imports, transpiles data classes.
tl check→tl gen→main.lua.- Stages
gamekit/andgamekit.d.tlinto the project directory if missing. - Runs
love <project_dir>.
gamekit/ is copied into the project directory only if it doesn't already exist. If you edit gamekit/ in the repo, run rm -rf examples/my_game/gamekit examples/my_game/gamekit.d.tl before tpp love to pick up the changes.
bundle / package§
tpp bundle§
Stages a distributable bundle: a directory with main.lua, assets/, gamekit/, plus a .love zip and a launcher script.
tpp bundle examples/showcase/main.tpp
# Bundled: ./build/main.love
# Launcher: ./build/main.sh
# Staging: ./build/main
Use --name showcase to name the artifact differently from the source:
tpp bundle examples/showcase/main.tpp --name showcase
# → build/showcase.love
# → build/showcase.sh
# → build/showcase/
tpp package§
Produces a distributable artifact. Requires --target.
tpp package main.tpp --target linux
tpp package main.tpp --target linux --love-bin /usr/bin/love
tpp package main.tpp --target windows --love-bin ~/love-11.4-win64
tpp package main.tpp --target macos --love-bin ~/love.app
tpp package main.tpp --target web
| Target | Output | Bundles LÖVE? |
|---|---|---|
| linux | dist/<name>-linux.tar.gz | Yes, with --love-bin <binary> |
| windows | dist/<name>-windows.zip | Yes, with --love-bin <dir-with-love.exe> |
| macos | dist/<name>-macos.tar.gz | Yes, with --love-bin love.app |
| web | dist/<name>-web.tar.gz | love.js player included |
See Packaging for details.
test / uninstall§
tpp test§
Runs the full suite:
- 6 Teal++ program/import checks
- 9 GameKit core tests (scene, input, timer)
- 26 GUI tests
- 7 window management tests
- 6 dock panel tests
- 5 camera math tests
- 12
GameKit.apptests - 10 data class tests
- Packaging smoke tests (Linux + web)
tpp test
# … 63 assertions + OK: packaging smoke (linux) + OK: packaging smoke (web)
tpp uninstall§
Removes the source install created by install.sh. It never touches your game projects or system packages:
tpp uninstall # prompts
tpp uninstall --yes # no prompt
The AppImage does not need uninstalling — just delete the file.
GameKit.app§
Zero-boilerplate application entry point. Opens a window, wires LÖVE's lifecycle, and dispatches to your callbacks.
Signature§
Configuration§
| Key | Type | Default | Notes |
|---|---|---|---|
title | string | "TGK Game" | Window title |
width | integer | 800 | Window width |
height | integer | 600 | Window height |
resizable | boolean | true | Window resize |
vsync | integer | 1 | 0 = off, 1 = on |
background | string | table | {0.02,0.03,0.06,1} | Hex or RGBA table |
clearOnDraw | boolean | true | Auto-clear each frame |
gui | UI | nil | Auto-attaches LoveGUI.attach |
gui_draw | boolean | true | Auto-draw GUI after user draw |
gui_renderer | Renderer | nil | Custom renderer (default: LoveGUI.new()) |
on_error | function | nil | Error handler |
load | function | nil | Lifecycle callback |
update | function | nil | Lifecycle callback |
draw | function | nil | Lifecycle callback |
keypressed | function | nil | Lifecycle callback |
keyreleased | function | nil | Lifecycle callback |
mousepressed | function | nil | Lifecycle callback |
mousereleased | function | nil | Lifecycle callback |
mousemoved | function | nil | Lifecycle callback |
wheelmoved | function | nil | Lifecycle callback |
textinput | function | nil | Lifecycle callback |
resize | function | nil | Lifecycle callback |
focus | function | nil | Lifecycle callback |
quit | function | nil | Return true to allow quit |
Callbacks and dispatch§
Every callback can be provided either in the config table or as a top-level global function:
-- Config style
GameKit.app({
update = function(dt) end,
draw = function() end,
})
-- Global style (identical result)
GameKit.app({})
function update(dt: number) end
function draw() end
Config wins if both are present. Global dispatch only fires for functions you actually defined — Lua builtins (like load, print) are never called accidentally.
GUI auto-wiring§
Pass a UI instance as gui and GameKit.app handles the rest:
local ui = GameKit.GUI.new({ x = 20, y = 20, w = 240 })
ui:label("score: 0")
ui:button("Click", function() end)
GameKit.app({ gui = ui })
Auto-attached:
LoveGUI.attach(ui)— mouse, keyboard, textinput, wheel, focusui:update(dt)each frameui:draw(renderer)after yourdraw
To disable auto-draw (composite manually): gui_draw = false. To use a custom renderer: gui_renderer = my_renderer.
Error handling§
Every callback is pcall-wrapped. A thrown error logs a traceback and the game keeps running:
GameKit.app({
on_error = function(traceback, err)
print("oops:", traceback)
end,
})
Return value§
GameKit.app returns an AppHandle with two methods:
local app = GameKit.app({})
app:onError(function(tb) end) -- replace the error handler
app:stop() -- request LÖVE quit
GameKit.graphics§
Hex-color-friendly wrapper over love.graphics. Every drawing call accepts a color as the last argument.
Colors§
Three forms accepted everywhere a color is expected:
"#rrggbb"— hex string"#rrggbbaa"— hex string with alpha{ r, g, b, a? }— table, components 0..1
GameKit.graphics.circle("fill", 100, 100, 20, "#15f5ff")
GameKit.graphics.circle("fill", 100, 100, 20, "#15f5ffcc") -- semi-transparent
GameKit.graphics.circle("fill", 100, 100, 20, {0.08, 0.96, 1, 1})
API§
Example§
function draw()
GameKit.graphics.clear("#0b0d18")
local w, h = GameKit.graphics.getDimensions()
GameKit.graphics.circle("fill", w/2, h/2, 48, "#15f5ff")
GameKit.graphics.rectangle("line", 20, 20, 200, 40, "#ffffff")
GameKit.graphics.print("score: 0", 20, 100, "#c0c0d0")
end
Accessing raw LÖVE§
For anything not wrapped, use raw():
local lg = GameKit.graphics.raw()
lg.setBlendMode("add")
lg.draw(my_mesh, 0, 0)
GameKit.assets§
Asset loader with caching. Missing files log a warning and return nil — the game degrades gracefully instead of crashing on startup.
API§
Usage§
local player = GameKit.assets.image("assets/player.png")
local ui_font = GameKit.assets.font("assets/ui.ttf", 18)
local beep = GameKit.assets.sound("assets/beep.wav")
local music = GameKit.assets.music("assets/theme.ogg")
-- Shorthand: default font at a size
local big = GameKit.assets.font(32)
-- Missing files return nil
if player then
GameKit.graphics.image(player, x, y)
end
Caching§
Every call caches by path. Repeated Assets.image("player.png") returns the same Image instance. Missing files also cache their failure, so you don't spam the console every frame.
Clearing§
Assets.clear() drops the entire cache. Use between scenes if memory is tight.
Missing assets should never be a fatal error in a game. A missing beep.wav means no sound — not a crash. Assets.* returns nil, logs once, and lets the game continue.
GameKit.audio§
Sound playback with fades. Master volume uses LÖVE's built-in master so it affects everything currently playing.
API§
Usage§
local beep = GameKit.assets.sound("assets/beep.wav")
local music = GameKit.assets.music("assets/theme.ogg")
GameKit.audio.play(beep, 0.6)
GameKit.audio.playMusic(music, 0.4)
GameKit.audio.setMasterVolume(0.8)
GameKit.audio.fadeOut(music, 2.0)
Fades§
fadeIn / fadeOut schedule a volume ramp. The ramp is updated by Audio.update(dt), which GameKit.app calls automatically each frame.
GameKit.audio.fadeIn(music, 1.5) -- ramp up over 1.5s
GameKit.audio.fadeOut(music, 2.0, function()
print("music ended")
end)
In compatibility-mode love.js (the bundled love.js.zip), audio may require a user click on the canvas before it starts. This is a browser autoplay policy, not a TGK limitation.
GameKit.errors§
Uniform error reporting. Used by GameKit.app internally; available for your own code.
API§
traceback§
Returns the error message plus a stack trace, when the runtime supports it:
local ok, err = pcall(risky_function)
if not ok then
io.stderr:write(GameKit.errors.traceback(err) .. "\n")
end
guard§
Wraps a callback so a thrown error is caught and routed to on_error:
local safe_update = GameKit.errors.guard(
function(dt) my_update(dt) end,
function(traceback) print(traceback) end,
"update"
)
fail / assert§
Prefixed error and assertion helpers:
GameKit.errors.assert(score >= 0, "score must not be negative")
GameKit.errors.fail("unreachable code")
GameKit.Input§
Action-based input bindings. Bind multiple physical sources to one named action, then query the action.
API§
Bindings§
GameKit.Input.bind("jump", "space", "j")
GameKit.Input.bind("fire", "mouse:1")
GameKit.Input.bind("left", "a", "left")
GameKit.Input.bind("right", "d", "right")
Sources are keyboard key names ("w", "space", "left") or "mouse:N" where N is the button number (1 = left, 2 = right, 3 = middle).
Querying§
function update(dt: number)
if GameKit.Input.down("left") then
player.x = player.x - 200 * dt
end
if GameKit.Input.pressed("jump") then
player.vy = -400 -- fires once per press
end
if GameKit.Input.released("fire") then
print("stopped firing")
end
end
Mouse§
local mx, my = GameKit.Input.mousePosition()
if GameKit.Input.mouseDown(1) then
-- left button held
end
Lifecycle§
GameKit.app wires keyboard and mouse events into Input automatically. When the window loses focus, Input.releaseAll() clears held state so keys don't "stick".
pressed and released are one-frame flags — cleared by Input.endFrame() after your update.
GameKit.Timer§
One-shot and repeating timers with cancellation.
API§
Usage§
-- One-shot
GameKit.Timer.after(2.0, function()
print("two seconds later")
end)
-- Repeating, 5 times
local id = GameKit.Timer.every(0.5, function()
spawn_enemy()
end, 5)
-- Cancel
GameKit.Timer.cancel(id)
Lifecycle§
GameKit.app calls Timer.update(dt) every frame before your update. Callback-created timers start firing on the next update (not the current one), preventing infinite loops.
Timers have catch-up protection: if the game is paused for a long time, they don't fire thousands of times to catch up.
GameKit.Scene§
Single-active-scene lifecycle. Scenes are tables with optional enter, leave, update, and draw methods.
API§
Example§
local menu = {}
function menu:enter(previous)
print("entered menu from", previous)
end
function menu:update(dt: number)
if GameKit.Input.pressed("jump") then
GameKit.Scene.switch(game_scene)
end
end
function menu:draw()
GameKit.graphics.print("Press Space", 100, 100, "#ffffff")
end
GameKit.Scene.switch(menu)
GameKit.app calls Scene.update and Scene.draw automatically. Your update / draw callbacks run after the active scene.
Transitions§
Scene.switch calls previous:leave(next) then next:enter(previous). Switching to the current scene is a no-op.
GameKit.Camera§
2D camera with world/screen coordinate conversion, rotation, and cursor-centered zoom.
API§
Usage§
local cam = GameKit.Camera.new({ width = 800, height = 600 })
function update(dt: number)
if GameKit.Input.down("right") then
cam:move(200 * dt, 0)
end
end
function draw()
cam:attachLove()
-- draw world objects here (affected by camera)
GameKit.graphics.circle("fill", 0, 0, 20, "#ffffff")
cam:detachLove()
-- draw HUD here (not affected by camera)
GameKit.graphics.print("score: 0", 20, 20, "#ffffff")
end
Cursor-centered zoom§
function wheelmoved(x: number, y: number)
local mx, my = GameKit.Input.mousePosition()
cam:zoomBy(1 + y * 0.1, mx, my)
end
GameKit.Window§
Window control — size, title, fullscreen, VSync, resize callbacks. LÖVE backend.
API§
Usage§
GameKit.Window.setTitle("My Game")
GameKit.Window.setSize(1280, 720)
GameKit.Window.setMinSize(640, 480)
GameKit.Window.setVSync(1)
if GameKit.Window.isFullscreen() then
GameKit.Window.toggleFullscreen()
end
GameKit.app({ title = ..., width = ..., height = ..., resizable = ..., vsync = ... }) calls Window.setMode and Window.setTitle on startup. Use GameKit.Window only for runtime changes.
GameKit.GUI§
Retained-mode GUI toolkit. Buttons, checkboxes, text inputs, sliders, dropdowns, radio groups, progress bars, containers, scrolling, tooltips, keyboard focus, and themes.
Creating a UI§
local ui = GameKit.GUI.new({
x = 20,
y = 20,
w = 240,
padding = 10,
spacing = 6,
})
Widgets§
ui:label("Score: 0")
ui:separator()
ui:button("Restart", function() reset() end)
ui:checkbox("Mute", false, function(checked: boolean)
GameKit.audio.setMasterVolume(checked and 0 or 1)
end)
ui:radio("Windowed", true, function() end, { group = "mode" })
ui:radio("Fullscreen", false, function() end, { group = "mode" })
ui:textinput("", function(text: string) print(text) end, { placeholder = "name" })
ui:slider(50, 0, 100, function(v: number) print(v) end, { label = "volume" })
ui:progressbar(0.5)
ui:dropdown({
items = { "Easy", "Normal", "Hard" },
selected = 2,
callback = function(index: integer, label: string) print(label) end,
})
Auto-attach with GameKit.app§
local ui = GameKit.GUI.new({ x = 20, y = 20, w = 240 })
ui:label("Hello")
GameKit.app({ gui = ui })
-- mouse, keyboard, wheel, textinput, and rendering are wired automatically
Without GameKit.app, wire manually:
GameKit.LoveGUI.attach(ui)
function love.update(dt)
ui:update(dt)
local mx, my = love.mouse.getPosition()
ui:mousemoved(mx, my)
end
function love.draw()
ui:draw(renderer)
end
Containers and scrolling§
local panel = ui:container({ height = 200, scrollable = true })
panel:label("Item 1")
panel:label("Item 2")
panel:button("Item 3", function() end)
Containers clip their contents and handle mouse wheel scrolling when scrollable = true.
Keyboard navigation§
- Tab / Shift+Tab — move focus
- Enter / Space — activate focused widget
- Arrow keys — adjust sliders, move through radio groups, navigate dropdowns
- Escape — close dropdowns / unfocus
Themes§
ui:setTheme({
accent = { 0.95, 0.45, 0.25, 1 },
background = { 0.06, 0.06, 0.09, 1 },
})
-- Or start with a built-in palette:
local ui = GameKit.GUI.new({ theme = GameKit.GUI.themes.light })
Tooltips§
ui:setTooltip(button, "Start a new game", 0.5)
The second argument is a per-widget delay override (seconds).
GameKit.Dock§
Dockable panel manager for editors and tools. Panels can be dragged by title bar, resized from a bottom-right grip, collapsed, and placed in left / right / top / bottom / float zones.
API§
Usage§
local dock = GameKit.Dock.new({ x = 16, y = 16, width = 1200, height = 680 })
local renderer = GameKit.LoveGUI.new()
local inspector = GameKit.GUI.new({ width = 280 })
inspector:label("Inspector")
inspector:button("Build", function() print("Build!") end)
dock:add("inspector", {
title = "Inspector",
x = 24, y = 24,
width = 300, height = 400,
content = inspector,
})
dock:add("scene", {
title = "Scene",
dock = "left",
width = 240,
})
local detach = GameKit.LoveGUI.attach(dock)
function love.draw() dock:draw(renderer) end
Packaging Overview§
tpp bundle produces a .love file and a launcher. tpp package wraps that .love into a platform-specific distributable.
Pipeline§
main.tpp
│
▼
build/<name>.love ← zip of main.lua + assets + gamekit
│
├──→ linux → dist/<name>-linux.tar.gz (run.sh + .love [+ love binary])
├──→ windows → dist/<name>-windows.zip (love.exe + .love + fused .exe)
├──→ macos → dist/<name>-macos.tar.gz (<name>.app + .love)
└──→ web → dist/<name>-web.tar.gz (love.js player + game.data)
Artifact naming§
By default the artifact name is derived from the source filename: examples/showcase/main.tpp → main-linux, main-windows, etc.
Pass --name <name> to use a different name:
tpp package examples/showcase/main.tpp --target linux --name showcase
# → dist/showcase-linux.tar.gz
The --love-bin flag§
Controls whether LÖVE is bundled with the game:
| Target | --love-bin value |
|---|---|
| linux | Path to the love binary |
| windows | Path to a directory containing love.exe and its DLLs |
| macos | Path to love.app |
| web | Path to love.js.zip (defaults to repo root) |
Without --love-bin, the artifact ships only the .love file and expects the user to have LÖVE installed.
Linux Packaging§
tpp package main.tpp --target linux
# → dist/main-linux.tar.gz (~20 KB)
With LÖVE bundled:
tpp package main.tpp --target linux --love-bin "$(command -v love)"
# → dist/main-linux.tar.gz (~2 MB)
Contents§
main-linux/
├── main.love ← the game (zip)
├── love ← (only with --love-bin)
├── run.sh ← launcher (executable)
└── README.txt ← (only without --love-bin)
Testing§
mkdir -p /tmp/test
tar -xzf dist/main-linux.tar.gz -C /tmp/test
cd /tmp/test/main-linux && ./run.sh
If command -v love returns a shell wrapper (not the ELF binary), the package will fail on machines where the wrapper's target doesn't exist. Use readlink -f "$(command -v love)" to resolve to the real binary.
Windows Packaging§
tpp package main.tpp --target windows --love-bin ~/love-11.4-win64
# → dist/main-windows.zip
--love-bin must point at a directory containing love.exe and its DLLs. Download the Windows LÖVE zip from love2d.org and extract it.
Contents§
main-windows/
├── main.exe ← fused love.exe + game (double-click)
├── main.love ← the raw game (drag onto love.exe)
├── love.exe
├── SDL2.dll
├── OpenAL32.dll
├── …
└── README.txt
How fusion works§
cat love.exe main.love > main.exe. LÖVE's binary reads the appended zip automatically. This is LÖVE's standard distribution mechanism — no external tooling beyond cat.
Players unzip, double-click main.exe, and play. No install, no dependencies, no Teal++, no Lua, no LÖVE.
Building on Linux§
You can produce a Windows .exe from Linux by passing the Windows LÖVE folder:
wget https://github.com/love2d/love/releases/download/11.4/love-11.4-win64.zip
unzip love-11.4-win64.zip -d ~/love-win64
tpp package main.tpp --target windows --love-bin ~/love-win64
macOS Packaging§
tpp package main.tpp --target macos --love-bin ~/love.app
# → dist/main-macos.tar.gz
Contents§
main-macos/
├── main.app/
│ └── Contents/
│ ├── MacOS/love
│ ├── Resources/
│ │ └── main.love
│ └── Info.plist
├── main.love
└── README.txt
Codesigning and notarization§
The pipeline produces an unsigned .app. For distribution:
codesign --deep --force --sign \
"Developer ID Application: Your Name (TEAMID)" main.app
ditto -c -k --keepParent main.app main.zip
xcrun notarytool submit main.zip --wait \
--apple-id you@example.com \
--password <app-specific-password> \
--team-id TEAMID
xcrun stapler staple main.app
For local testing on your own Mac, unsigned .app bundles usually launch. Users who download them see a Gatekeeper warning until notarized.
Web Packaging (love.js) new§
tpp package --target web produces a browser-playable build using the prebuilt love.js player bundled as love.js.zip at the repo root.
tpp package main.tpp --target web
# → dist/main-web.tar.gz
Contents§
main-web/
├── index.html
├── love.wasm
├── love.js
├── game.data ← your compiled .love file
└── README.txt
Running locally§
Web builds cannot be opened from file://. Serve them over HTTP:
mkdir -p /tmp/web
tar -xzf dist/main-web.tar.gz -C /tmp/web
cd /tmp/web/main-web
python3 -m http.server 8080
# open http://localhost:8080
Deploying§
Upload every file in main-web/ to any static host — itch.io, GitHub Pages, Netlify, Cloudflare Pages. The game itself is game.data; replace it to update the game without reuploading the player.
Compatibility mode§
The bundled love.js.zip uses the compatibility build (no pthreads). This means:
- Audio may require a click on the canvas before playing (browser autoplay policy).
- No COOP/COEP headers needed — works on itch.io and GitHub Pages out of the box.
If you swap in a non-compatibility love.js build, add these headers to your server:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Known limitations§
- WebGL only; no Vulkan or Metal shaders.
- Larger games take longer to load (
.wasm+.datadownload). love.filesystemwrites are not persisted (browser sandbox). UselocalStoragefor saves.- Audio may be slightly delayed in compatibility mode.
Examples§
The repo ships seven runnable examples. All compile clean with tl check and run on LÖVE 11+.
| Path | What it demonstrates |
|---|---|
examples/hello/main.tpp | 10 lines. Window + circle. The minimum viable Teal++ game. |
examples/gui/main.tpp | GUI auto-wiring — buttons, checkbox, live counter, animated canvas. |
examples/showcase/main.tpp | Movement, action input, timers, GUI, audio, sprite rendering, mouse tracking. |
examples/neon_survivor/main.tpp | Full arcade survival game: auto-fire targeting, waves, particles, difficulty dropdown. |
examples/dataclass/main.tpp | Data classes with __tostring, __eq, copy, and a custom method. |
examples/tealpp/main.tpp | Starter template — copy this to start a new project. |
examples/love/ | Direct LÖVE demos (no .tpp compile step). Tests GameKit standalone. |
Running any example§
tpp love examples/showcase/main.tpp
tpp love examples/gui/main.tpp
tpp love examples/dataclass/main.tpp
Data class example§
import gamekit as GameKit
data class Point(x: number, y: number)
public dist(other: Point): number
local dx = self.x - other.x
local dy = self.y - other.y
return math.sqrt(dx * dx + dy * dy)
end
public scale(factor: number): Point
return Point.new(self.x * factor, self.y * factor)
end
end
local p1 = Point.new(3, 4)
local p2 = Point.new(3, 4)
local origin = Point.new(0, 0)
print(p1) -- Point(x=3, y=4)
print(p1 == p2) -- true
print(p1 == origin) -- false
print(p1:dist(origin)) -- 5.0
print(p1:copy() == p1) -- true
Troubleshooting§
"GameKit.app: error in load callback"§
You have a global function named after a Lua builtin (load, print, error, pcall). GameKit.app's dispatcher guards against this — it snapshots _G at load time and only dispatches to functions whose value has changed since the snapshot. If you see this error, you've probably shadowed a builtin somewhere else.
Rename your callback or check for accidental globals.
"functions need an explicit 'local' or 'global' annotation"§
You have function foo() at top level in a Teal file. Add global:
global function foo() end
Or move it inside a local scope. This is Teal's safety rule — no implicit globals.
"cannot index key 'X' in variable 'GameKit' of type boolean"§
gamekit.d.tl failed to parse, so Teal treats require("gamekit") as returning boolean. Scroll up in the check output — there's a real syntax error earlier in gamekit.d.tl. Fix that and the boolean errors vanish.
"in assignment: got number, expected integer"§
local n = 0 infers integer. n + dt is number. Assigning back fails. Use local n = 0.0.
"cannot index key 'x' in variable 'target' of type Enemy | nil"§
Teal didn't narrow after if not target then return end. Cast after the guard:
if not target then return end
local enemy = target as Enemy
print(enemy.x)
Staged GameKit copy is stale§
tpp love copies gamekit/ into the project directory only if it doesn't exist. After editing gamekit/:
rm -rf examples/my_game/gamekit examples/my_game/gamekit.d.tl
tpp love examples/my_game/main.tpp
Web build shows a blank page§
- Open browser devtools → Network. Look for a failed
.wasmrequest (404 = missing file). - Console tab. Look for
game.data not foundor WebGL errors. - Confirm you're serving over HTTP, not
file://. - Confirm
game.datais present in the served directory.
AppImage won't run§
chmod +x tealpp-*.AppImage
./tealpp-*.AppImage --help
If --help works, the AppImage is fine — the problem is in your game code or LÖVE install.
Accidentally hid system Lua§
If you ran sudo mv /usr/local/bin/lua /tmp/lua.bak during a self-containment test and the restore failed (read-only /usr on Fedora Atomic / Bazzite):
sudo mv /tmp/lua.bak /usr/local/bin/lua
sudo chmod +x /usr/local/bin/lua
lua -v
Safer test for AppImage self-containment without touching system files:
env PATH=/usr/bin:/bin ./tealpp-*.AppImage --help
API Quick Reference§
GameKit.app§
GameKit.app({
title, width, height, resizable, vsync,
background, clearOnDraw,
gui, gui_draw, gui_renderer, on_error,
load, update, draw,
keypressed, keyreleased,
mousepressed, mousereleased, mousemoved, wheelmoved,
textinput, resize, focus, quit,
}) → AppHandle
app:onError(fn)
app:stop()
GameKit.graphics§
clear(color?) setColor(color)
circle(mode, x, y, r, color?, seg?)
rectangle(mode, x, y, w, h, color?)
line(x1, y1, x2, y2, color?)
print(text, x?, y?, color?)
image(img, x, y, r?, sx?, sy?)
polygon(mode, color, ...)
ellipse(mode, x, y, rx, ry, color?, seg?)
arc(mode, x, y, r, a1, a2, color?, seg?)
getDimensions() → w, h
setFont(font) setLineWidth(w)
push() pop()
translate(x, y) scale(sx, sy?)
rotate(r) reset()
raw() → love.graphics
GameKit.assets§
image(path) → Image?
font(path_or_size, size?) → Font?
sound(path) → Source?
music(path) → Source?
clear()
GameKit.audio§
play(sound, volume?, pitch?) → Source?
playMusic(sound, volume?)
stopMusic() stopAll()
setMasterVolume(v)
fadeIn(source, duration, target?)
fadeOut(source, duration, on_complete?)
update(dt)
GameKit.Input§
bind(action, ...sources)
unbind(action)
down(action) → boolean
pressed(action) → boolean
released(action) → boolean
mousePosition() → x, y
mouseDown(button?) → boolean
releaseAll() reset()
GameKit.Timer§
after(delay, callback) → id
every(interval, callback, repetitions?) → id
cancel(id) → boolean
clear() update(dt)
GameKit.Scene§
switch(next_scene?)
current() → any
update(dt) draw(...)
reset()
GameKit.GUI§
GUI.new(options) → UI
GUI.themes.dark, GUI.themes.light
ui:label(text, options?)
ui:separator(options?)
ui:button(text, callback?, options?)
ui:checkbox(text, checked, callback?, options?)
ui:textinput(text, callback?, options?)
ui:radio(text, selected, callback?, options?)
ui:dropdown(options?)
ui:slider(value, min, max, callback?, options?)
ui:progressbar(value, options?)
ui:container(options?)
ui:setValue(widget, value, silent?)
ui:setEnabled(widget, enabled)
ui:setTheme(theme) ui:getTheme() → theme
ui:setTooltip(widget, text, delay?)
ui:update(dt) ui:draw(renderer)
ui:mousemoved(x, y)
ui:mousepressed(x, y, button)
ui:mousereleased(x, y, button)
ui:keypressed(key)
ui:handleTextInput(text)
ui:wheelmoved(x, y)
ui:releaseAll()
ui:setBounds(x, y, w, h)
ui:resize(w, h)
GameKit.Camera§
Camera.new(options) → Camera2D
cam:setPosition(x, y)
cam:move(dx, dy)
cam:setZoom(zoom, sx?, sy?)
cam:zoomBy(factor, sx?, sy?)
cam:setRotation(radians)
cam:setViewport(x, y, w, h)
cam:worldToScreen(x, y) → sx, sy
cam:screenToWorld(x, y) → wx, wy
cam:containsScreenPoint(x, y) → boolean
cam:getVisibleWorldBounds() → x, y, w, h
cam:attachLove() cam:detachLove()
GameKit.Window§
getSize() → w, h setSize(w, h)
setTitle(title) setResizable(bool)
setMinSize(w, h)
setFullscreen(enabled, mode?)
toggleFullscreen(mode?) → bool
setBorderless(bool)
isFullscreen() → bool
setVSync(value) configure(options)
GameKit.Dock§
Dock.new(options) → Dock
dock:add(id, options) → Panel
dock:get(id) → Panel?
dock:remove(id) → boolean
dock:setBounds(x, y, w, h)
dock:resize(w, h)
dock:setDock(panel_or_id, zone)
dock:setVisible(panel_or_id, visible)
dock:toggleCollapsed(panel_or_id) → boolean
dock:layout()
dock:draw(renderer)
dock:mousepressed(x, y, button)
dock:mousemoved(x, y, dx, dy)
dock:mousereleased(x, y, button)
dock:keypressed(key)
dock:releaseAll()
GameKit.errors§
traceback(err) → string
guard(fn, on_error?, label?) → function
fail(msg)
assert(cond, msg?) → any
Compatibility Notes§
Teal versions§
Teal++'s generated .tl is checked by whichever tl binary you have installed. Some Teal versions vary in:
- Optional record fields.
field?: typefails to parse on some versions. Teal++ avoids this by typing config-style records asany. - Union narrowing.
if not x then return end; x.fieldsometimes fails to narrow. Workaround: cast withas. - Integer vs number. Consistent across versions:
0is integer,0.0is number.
The bundled AppImage ships tl 0.24.8. If you install tl via luarocks, any version from 0.20+ should work.
LÖVE versions§
| LÖVE | Status |
|---|---|
| 11.4 | Tested — recommended |
| 11.3 | Works |
| 12.x | Not tested; API is mostly unchanged |
| 0.10.x | Not supported |
Platforms§
| Platform | Dev toolchain | Game distribution |
|---|---|---|
| Linux x86_64 | AppImage or source install | .love, .tar.gz |
| Windows 10/11 | PowerShell installer or Git Bash | .zip with fused .exe |
| macOS 11+ | Source install via install.sh | .app (unsigned; notarize separately) |
| Web (browsers) | Same Linux/macOS toolchain | love.js player + game.data |
Known limitations§
- No joystick / gamepad / touch dispatch. Keyboard and mouse are fully wired. Controller support is future work.
- No
love.filesystempersistence on web. Browser sandbox; uselocalStorage. - No save-file system. Games write their own; TGK provides no serialization layer.
- No cross-compilation.
tpp package --target windowsrequires the Windows LÖVE folder.--target macosrequireslove.app. - Windows packaging requires POSIX tools (
zip,tar,cat,cp). Use WSL or Git Bash. This affects only the packaging step, not games.
What's next§
Not promised, not scheduled, but on the roadmap:
- Bundled LÖVE in the AppImage so
tpp loveworks on a bare system with zero system dependencies. GameKit.Sprite,GameKit.Collision,GameKit.Save— the pieces games end up hand-rolling.- Controller dispatch through
GameKit.app. tpp init— scaffold a new project in one command.- GitHub Actions — build the AppImage and Windows installer on tag push.
- AppImageHub submission.