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 bundle and tpp package for Linux, Windows, macOS, and the web.
  • A self-contained installer — 1.4 MB Linux AppImage with Lua 5.4 and the tl compiler bundled.
  • Type safety — every TGK module ships with Teal declarations; tl check catches 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 .lua file.
  • You can hand-edit the Lua if you need to (though you shouldn't — edit the .tpp instead).
  • 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 → .lua workflow.
Teal++ vs. Teal

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:

  • Lua 5.4 — the runtime language.
  • Teal — the type checker and .tl → .lua compiler.
  • LÖVE 11 — the game engine TGK wraps.

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.

LÖVE is not bundled

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 tpp wrapper to ~/.local/bin/tpp.
  • Prints the exact export PATH=… line you need if ~/.local/bin isn't on your PATH.

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§

ToolNeeded forNotes
luaRunning the CLIBundled in the AppImage. On source installs, install Lua 5.4.
tltl check / tl genBundled in the AppImage. On source installs: luarocks install tl.
lovetpp love, playing gamesNot bundled. Install LÖVE 11+ separately.
ziptpp bundle / tpp packageUsually preinstalled. apt install zip if missing.
unziptpp package --target webUsually preinstalled.
gitInstaller onlyNot 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§

  1. tpp love detected the import … as … line and chose program mode.
  2. compile_program lowered the import to local GameKit = require("gamekit").
  3. tl check type-checked the result against gamekit.d.tl.
  4. tl gen produced hello.lua.
  5. love . ran the game. GameKit.app wrapped LÖVE's lifecycle and dispatched draw() 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++.

Integers vs numbers

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 … end blocks → 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 levelprogram
Only class X … endclass
Neitherclass (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.

Regular classes need class mode

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§

FeatureSyntax
Fieldspublic name: type / private name: type
Constructorconstructor(params)
Auto-assignmentconstructor(@x: number) — @ means self.x = x
Methodspublic name(params): return_type
Metamethodspublic __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 class declarations 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) — constructor
  • self.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§

FeatureStatus
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
Practical data classes for games

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 foo without as is 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§

IssueWorkaround
Optional fields in records (field?: type) fail to parseUse any for config-style records; validate at runtime
local n = 0 infers integer; adding dt failsUse local n = 0.0
Union not narrowed after nil guardCast with x as T after the guard

CLI Overview§

Every command shares the same option surface:

OptionMeaning
-o, --output <path>Output .tl path (build/check/run/love); output directory (bundle/package)
--programForce program mode
--classForce class mode
--no-checkSkip 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, --helpShow help

Command summary§

CommandProduces
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 testruns the full test suite
tpp uninstallremoves 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:

  1. Detects program mode (because of the import).
  2. Lowers imports, transpiles data classes.
  3. tl check → tl gen → main.lua.
  4. Stages gamekit/ and gamekit.d.tl into the project directory if missing.
  5. Runs love <project_dir>.
Staged GameKit copies

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
TargetOutputBundles LÖVE?
linuxdist/<name>-linux.tar.gzYes, with --love-bin <binary>
windowsdist/<name>-windows.zipYes, with --love-bin <dir-with-love.exe>
macosdist/<name>-macos.tar.gzYes, with --love-bin love.app
webdist/<name>-web.tar.gzlove.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.app tests
  • 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§

GameKit.app(config: table): AppHandle

Configuration§

KeyTypeDefaultNotes
titlestring"TGK Game"Window title
widthinteger800Window width
heightinteger600Window height
resizablebooleantrueWindow resize
vsyncinteger10 = off, 1 = on
backgroundstring | table{0.02,0.03,0.06,1}Hex or RGBA table
clearOnDrawbooleantrueAuto-clear each frame
guiUInilAuto-attaches LoveGUI.attach
gui_drawbooleantrueAuto-draw GUI after user draw
gui_rendererRenderernilCustom renderer (default: LoveGUI.new())
on_errorfunctionnilError handler
loadfunctionnilLifecycle callback
updatefunctionnilLifecycle callback
drawfunctionnilLifecycle callback
keypressedfunctionnilLifecycle callback
keyreleasedfunctionnilLifecycle callback
mousepressedfunctionnilLifecycle callback
mousereleasedfunctionnilLifecycle callback
mousemovedfunctionnilLifecycle callback
wheelmovedfunctionnilLifecycle callback
textinputfunctionnilLifecycle callback
resizefunctionnilLifecycle callback
focusfunctionnilLifecycle callback
quitfunctionnilReturn 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, focus
  • ui:update(dt) each frame
  • ui:draw(renderer) after your draw

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§

GameKit.graphics.clear(color?) GameKit.graphics.setColor(color) GameKit.graphics.circle(mode, x, y, radius, color?, segments?) GameKit.graphics.rectangle(mode, x, y, w, h, color?) GameKit.graphics.line(x1, y1, x2, y2, color?) GameKit.graphics.print(text, x?, y?, color?) GameKit.graphics.image(img, x, y, r?, sx?, sy?) GameKit.graphics.polygon(mode, color, ...) GameKit.graphics.ellipse(mode, x, y, rx, ry, color?, segments?) GameKit.graphics.arc(mode, x, y, r, a1, a2, color?, segments?) GameKit.graphics.getDimensions(): number, number GameKit.graphics.setFont(font) GameKit.graphics.setLineWidth(w) GameKit.graphics.push() GameKit.graphics.pop() GameKit.graphics.translate(x, y) GameKit.graphics.scale(sx, sy?) GameKit.graphics.rotate(radians) GameKit.graphics.reset() GameKit.graphics.raw(): love.graphics

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§

GameKit.assets.image(path): Image? GameKit.assets.font(path_or_size, size?): Font? GameKit.assets.sound(path): Source? GameKit.assets.music(path): Source? GameKit.assets.clear()

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.

Design philosophy

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§

GameKit.audio.play(sound, volume?, pitch?): Source? GameKit.audio.playMusic(sound, volume?) GameKit.audio.stopMusic() GameKit.audio.stopAll() GameKit.audio.setMasterVolume(v) GameKit.audio.fadeIn(source, duration, target?) GameKit.audio.fadeOut(source, duration, on_complete?) GameKit.audio.update(dt)

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)
Web builds

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§

GameKit.errors.traceback(err): string GameKit.errors.guard(fn, on_error?, label?): function GameKit.errors.fail(msg) GameKit.errors.assert(cond, msg?): any

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§

GameKit.Input.bind(action, ...sources) GameKit.Input.unbind(action) GameKit.Input.down(action): boolean GameKit.Input.pressed(action): boolean GameKit.Input.released(action): boolean GameKit.Input.mousePosition(): number, number GameKit.Input.mouseDown(button?): boolean GameKit.Input.releaseAll() GameKit.Input.reset()

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§

GameKit.Timer.after(delay, callback): id GameKit.Timer.every(interval, callback, repetitions?): id GameKit.Timer.cancel(id): boolean GameKit.Timer.clear() GameKit.Timer.update(dt)

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§

GameKit.Scene.switch(next_scene?) GameKit.Scene.current(): any GameKit.Scene.update(dt) GameKit.Scene.draw(...) GameKit.Scene.reset()

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§

local cam = GameKit.Camera.new({ x?, y?, zoom?, rotation?, width?, height? }) cam:setPosition(x, y) cam:move(dx, dy) cam:setZoom(zoom, screen_x?, screen_y?) cam:zoomBy(factor, screen_x?, screen_y?) cam:setRotation(radians) cam:setViewport(x, y, w, h) cam:worldToScreen(x, y): number, number cam:screenToWorld(x, y): number, number cam:containsScreenPoint(x, y): boolean cam:getVisibleWorldBounds(): number, number, number, number cam:attachLove() cam:detachLove()

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§

GameKit.Window.getSize(): number, number GameKit.Window.setSize(width, height) GameKit.Window.setTitle(title) GameKit.Window.setResizable(resizable) GameKit.Window.setMinSize(width, height) GameKit.Window.setFullscreen(enabled, mode?) GameKit.Window.toggleFullscreen(mode?): boolean GameKit.Window.setBorderless(enabled) GameKit.Window.isFullscreen(): boolean GameKit.Window.setVSync(value) GameKit.Window.configure(options)

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 already configures the window

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§

local dock = GameKit.Dock.new({ x?, y?, width?, height? }) dock:add(id, { title?, width?, height?, x?, y?, dock?, content?, onClose?, onFocus? }) 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()

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
linuxPath to the love binary
windowsPath to a directory containing love.exe and its DLLs
macosPath to love.app
webPath 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
Wrapper scripts

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 + .data download).
  • love.filesystem writes are not persisted (browser sandbox). Use localStorage for 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+.

PathWhat it demonstrates
examples/hello/main.tpp10 lines. Window + circle. The minimum viable Teal++ game.
examples/gui/main.tppGUI auto-wiring — buttons, checkbox, live counter, animated canvas.
examples/showcase/main.tppMovement, action input, timers, GUI, audio, sprite rendering, mouse tracking.
examples/neon_survivor/main.tppFull arcade survival game: auto-fire targeting, waves, particles, difficulty dropdown.
examples/dataclass/main.tppData classes with __tostring, __eq, copy, and a custom method.
examples/tealpp/main.tppStarter 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§

  1. Open browser devtools → Network. Look for a failed .wasm request (404 = missing file).
  2. Console tab. Look for game.data not found or WebGL errors.
  3. Confirm you're serving over HTTP, not file://.
  4. Confirm game.data is 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?: type fails to parse on some versions. Teal++ avoids this by typing config-style records as any.
  • Union narrowing. if not x then return end; x.field sometimes fails to narrow. Workaround: cast with as.
  • Integer vs number. Consistent across versions: 0 is integer, 0.0 is 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ÖVEStatus
11.4Tested — recommended
11.3Works
12.xNot tested; API is mostly unchanged
0.10.xNot supported

Platforms§

PlatformDev toolchainGame distribution
Linux x86_64AppImage or source install.love, .tar.gz
Windows 10/11PowerShell installer or Git Bash.zip with fused .exe
macOS 11+Source install via install.sh.app (unsigned; notarize separately)
Web (browsers)Same Linux/macOS toolchainlove.js player + game.data

Known limitations§

  • No joystick / gamepad / touch dispatch. Keyboard and mouse are fully wired. Controller support is future work.
  • No love.filesystem persistence on web. Browser sandbox; use localStorage.
  • No save-file system. Games write their own; TGK provides no serialization layer.
  • No cross-compilation. tpp package --target windows requires the Windows LÖVE folder. --target macos requires love.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 love works 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.