Introducing: hxpico8

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.

Project repository · Examples

Have fun!

Related posts:

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.