Have you heard of PICO-8? It's a "fantasy console" with little built-in sprite/code/level/sound/music editors and a carefully crafted spec. And (slightly changed) Lua scripting. And a web player export (example). A rather interesting option if you like working with restrictions and/or tiny pixelart.
Long story short, I've made a little Haxe compiler target that generates compact Lua code that runs on PICO-8. This post covers reasoning, some technical details, and tricks used to accomplish this.
Reasoning
First, let's get a bit on this "why would you do such a thing".
As it was previously mentioned, PICO-8 has enforced restrictions, including those on cart section sizes. So you get a limit of 8192 tokens or 16 384 bytes of compressed Lua code.
Naturally, as you approach the code size limit, it becomes more and more apparent that you'll have to optimize some parts of code. And it's not optimizing for the sake of elegant design, but for smaller size and higher performance.
And, quite unsurprisingly, size optimizations do not exactly make the code easier to read.
So what if you were to not write the compressed+optimized code by hand but have it generated from a clean and humanly readable work copy? That's something that Haxe is good for.
Having a free weekend and a handful of existing experience in using "JS Generator API" for compiling Haxe into arbitrary programming languages, I have decided to make a small project that does just that.
How it works
Of course, in reality the things are a bit trickier. While the compiler generates a generally readable AST (more or less reflecting the input Haxe code), it has quite a taste for making numerous additional local variables. And sometimes spitting out v = { ...; value }
for some reason.
So that has to be taken care of by an an average-sized class before printing.
But the result is quite pleasant: if you have the code being
class Test { static inline function main() { trace("Hello!"); } }
the generated Lua code is just a single line:
print("test.hx:3: Hello!")
Basic structures
Now let's take a look at something a bit more complex:
var a = 1; var n = Math.random(); trace("A number: " + (a + n));
Here we have two local variables, and this would normally be 3 lines of code, but the mini-optimizer takes care of it, "merging in" single-use variables:
print("test.hx:5: A number: " .. (1 + rnd(1)))
If you want a local variable to not be "merged in", you can initialize it as var v; v = value
- it will be compiled to local v = value
. Originally I was going to use @:keep var v = value
for hinting this, but there's currently a bug related to adding metadata to variable declarations.
Other bits of syntax are also matched and mapped accordingly where possible. For example,
for (i in 0 ... 5) trace(i);
becomes
for i = 0, 4 do print("test.hx:3: " .. i) end
And there is also a loop
macro-function in the Pico
class, so you can import
it (or the whole class, since it contains all global scope functions of the console) and do
for (i in loop(1, 5, 2)) trace(i);
to get a conventional Lua arithmetic loop with an optional "step" argument:
for i = 1, 5, 2 do print("test.hx:4: " .. i) end
Lua does not have support for a switch() statement, so
switch (Math.floor(Pico.rand(5))) { case 0: trace("1/5"); case 1, 2: trace("2/5"); default: trace("3/5"); }
is converted to
_g = flr(rnd(5)) if _g == 0 then print("test.hx:4: 1/5") elseif _g == 1 or _g == 2 then print("test.hx:5: 2/5") else print("test.hx:6: 3/5") end
which, while not insanely compact, suits it's needs.
Currently unsupported structures are:-
try { ... } catch (...) { ... }
: PICO-8 does not have functions for error handling so these currently cannot be implemented. -
i++
/i--
/++i
/--i
: Additional syntax analysis is required to make these work.
Standard library coverage
Currently very little of standard Haxe library is supported, since PICO-8 only supports a certain subset of standard Lua library.
However you'll likely want to use PICO-8's API at most times anyway, both because the builtin functions do not add any weight to the resulting code size and since they are specifically suited for things to be made on the system.
Static fields
But now for the interesting part: class fields. Say, you have a function and a static variable:
class Test { static var some = 1; static function showSome() { trace(some); some += 1; } static inline function main() { showSome(); } }
You could probably expect to see some metatable for the Test class containing both, but it's the tokens that count, so it's more efficient to make both global:
-- Test: function Test_showSome() print("test.hx:4: " .. Test_some) Test_some += 1 end -- Test: Test_some = 1 -- Test_showSome()
Where first section is class members, and the second is class field initializations. Comments do not count for token total in PICO-8 so they can be kept in debug builds for annotation purposes just fine.
And, of course, since that function is only used once, you could also mark it as "inline" and get short code as result:
-- Test: Test_some = 1 -- print("test.hx:4: " .. Test_some) Test_some += 1
Instance fields
Usually you would probably use Lua' metatables for inheritance in class fields, but PICO-8' Lua currently does not have any of that, so one has to be creative.
The approach that I've taken is similar to how C++ works: variables are stored in the class instance while method references are calculated compile-time unless a field is marked as virtual (in this case, dynamic), so
class Test { var text:String; function new(s:String) { this.text = s; } function print() trace(text); dynamic function dynprint() trace(text); static inline function main() { var t = new Test("Hello!"); t.print(); t.dynprint(); } }
becomes
-- Test: function Test_print(this) print("test.hx:6: " .. this.text) end function Test_dynprint(this) print("test.hx:7: " .. this.text) end function Test_create(s) return { dynprint = Test_dynprint, text = s } end -- t = Test_create("Hello!") Test_print(t) t:dynprint()
Usually you would want to use dynamic functions only if a value can be either of multiple child classes, but both cases are fairly optimized, as you can notice.
And, as usual, you can mark functions as inline to suggest the compiler to inline them or @:extern inline to force the compiler to inline them.
Enum/ADT
Enums/algebraic data types are always inlined. If an enum is "simple" (none of constructors have parameters), it is converted into a set of constant values, so
class Test { static inline function main() { var co = new Collection(); co.add(A); co.add(B); for (v in co) { switch (v) { case A: trace("A"); case B: trace("B"); } } } } enum Simple { A; B; }
becomes
co = { } add(co, 0) add(co, 1) for v in all(co) do _g_switch = v if _g_switch == 0 then print("test.hx:8: A") elseif _g_switch == 1 then print("test.hx:9: B") end end
(add
and all
are the built-in functions for adding a value and iterating over values in a table accordingly)
If one or more constructors of an enum have parameters, constructor calls are converted into tables instead, so
class Test { static inline function main() { var co = new Collection(); co.add(Simple); co.add(Param(4)); for (v in co) { switch (v) { case Simple: trace("Simple"); case Param(x): trace('Param($x)'); } } } } enum ADT { Simple; Param(x:Fixed); }
becomes
co = { } add(co, { 0 }) add(co, { 1, 4 }) for v in all(co) do _g_switch = v[1] if _g_switch == 0 then print("test.hx:8: Simple") elseif _g_switch == 1 then print("test.hx:9: Param(" .. v[2] .. ")") end end
In conslusion
hxpico8 allows to leverage the power of Haxe for creating PICO-8 applications while maintaining control over the output size and structure. The compiler permits various measures to be taken for optimizing output size and performance while keeping the actual source code tidy. A number of examples (along with compiled files for comparison) can be found in the according repository.
Have fun!