▶ YouTube Summaries

← All summaries

Migrating the Love2D Pong game to Teal, a statically typed Lua

2026-10-17 Sat ⏱ 1 hr 45 min tsodingdaily

Tsoding's main complaint about Lua after the previous Pong stream was dynamic typing, which makes refactoring and extending code painful. Chat suggested Teal, a statically typed Lua dialect that compiles to plain Lua, roughly what TypeScript is to JavaScript. He has never used it before. He downloads the prebuilt binary, migrates his 269-line Love2D Pong game to Teal by writing his own minimal Love2D type declarations, and then uses the type checker to add a pause feature with a "PAUSED" label drawn in the game's segment-style pixel font. His verdict is that it is the smoothest language adoption he has seen, and that Teal is now his default for any Lua work, the same way TypeScript is for JavaScript. His one serious complaint is that tl gen emits Lua without type checking.

Getting started

  • Docs suggest installing via LuaRocks. He refuses ("npm-style" package manager that even had a security incident in September) and grabs the prebuilt Linux tarball instead. tl is an almost static ELF binary, and Teal is written in Teal.
  • Running tl with no arguments only says "a command is required", with no list of commands and no hint. He compares it to his own issue tracker tatr, which prints usage when run bare. tl --help works.
  • Commands: check (type check), gen (emit .lua), run (run a .tl script with a built-in Lua interpreter, so no external Lua is needed), warnings, types, and completion (bash completion; he wonders why that is a subcommand).
  • tl gen on an empty main.tl overwrote his real main.lua with a single newline. Version control saved the game.

Language basics he ran into

  • Functions need an explicit local or global. There is no gradual typing: untyped parameters are errors, and the tool warns about unused functions and variables.
  • Generated Lua simply has the type annotations stripped.
  • Arrays are written {number} and 2D arrays {{number}}, mirroring table literal syntax. He likes this.
  • Maps are {K:V}. Records model Lua tables with fields. Methods declared with : need the self type as an explicit first argument in the function type.
  • There is a separate integer subtype of number. Literals such as 0 infer as integer, so variables that later hold floats must be annotated number. Scores, on the other hand, should be integers. He finds it "too strong now", but prefers that to too weak.
  • There are no TypeScript-style string literal types ("fill" | "line"), which he calls the coolest part of TypeScript. Teal has enum instead, and enums cannot be declared inside a record; they must be top level (local enum DrawMode "fill" "line" end).
  • Values are nullable by default. Joystick | nil did not narrow after a nil check, so method calls failed. Dropping the union fixed it.
  • No +=. He thinks a transpiler should have added it.
  • The error count is printed at both the top and the bottom of the output, which he likes.

Writing Love2D declarations by hand

  • Third-party types go in a .d.tl file loaded with require. Full generated Love2D typings exist on GitHub, but he only uses a small part of the API, so he writes his own love.d.tl: graphics (rectangle, clear, line, setLineWidth, getWidth), window (setMode, setTitle), audio (newSource, play, setVolume), joystick (getJoysticks returning {Joystick}), keyboard.isDown, and enums DrawMode, SourceType, KeyConstant and GamepadButton containing only the values the game uses.
  • He got stuck on defining the love.load, love.update and love.draw callbacks in main.tl: Teal refuses to add undeclared functions to a record outside the scope where it was declared. The fix (found in someone's Love2D Teal repo) is to declare the callback signatures in the .d.tl record first. Not obvious, and the docs have no example.
  • Records and enums referenced across files must be global, not local.
  • He installed the six-year-old teal-mode for Emacs from GitHub. It works, though it doesn't highlight =enum=/=type= and indents oddly.
  • The error count dropped steadily from about 70 to zero. The generated Lua runs unchanged. Comments are lost in the output.
  • Build process: commit only .tl files, gitignore .lua, run tl gen main.tl && love .. Users already have to install Love2D, so installing Teal too is fine ("we don't commit a C compiler to C projects").

Adding pause, guided by the type checker

  • A first attempt used love.keyboard.isDown("space") even though space was not in KeyConstant, and it still compiled. That's because tl gen does not type check at all; only check and run do. He finds this split absurd: no C compiler lets you skip checking and generate code anyway.
  • isDown is the wrong tool for a toggle. He switches to the love.keypressed(key, scancode, isrepeat) callback, declares it in love.d.tl, and uses _ for unused parameters.
  • Renaming render_digit to render_glyph and digit_size to glyph_size by "just following compilation errors" is the moment that sells him: "this is why static typing won". He grants that dynamic typing suits some problems but not a whole language.
  • He adds letter glyphs P, A, U, S, E, D to the segment font. Then he makes the font a map keyed by a FontGlyphs enum, so text is a {FontGlyphs} array instead of a string. Rendering a letter that has no glyph becomes a compile error. That is fine here because all text is static.
  • text_width uses #text. The "PAUSED" label is centred by subtracting half its width and height (5 glyph rows), and the checker caught a typo along the way.
  • Implementing pause felt stress-free "because I know it's all type checked".