How to Create 3D Games with PureScript Native and C++

When I last wrote about binding PureScript to C++, I demonstrated animating the PureScript logo with SFML. Since then, PureScript Native (PSN) has superseded Pure11 and the details for using PSN have changed.

To make this concrete, I’ll go over what it took to create Lambda Lantern — a 3D game about functional programming patterns. Originally Lambda Lantern started off as a GitHub Game Off submission. Since you only have 30 or so days to finish, some shortcuts had to be made. I plan to address the shortcuts and really flesh out the game using it as a vehicle to drive the C++ binding and the ecosystem around it.

💡 If you’re not looking to make a game but would still like to bind PureScript to C++ code or maybe you’re looking for an alternative to binding Haskell to C++ — no worries — I’ve tried to keep this guide as general as possible.

Setting up the Project

Git clone PSN and build it with stack like you would any other Haskell Stack project.

git clone https://github.com/andyarvanitis/purescript-native
cd purescript-native
stack install
cd

Once it finishes, you’ll end up with the PureScript to C++ compiler called pscpp. If you stack installed, it should be in your local bin directory. In any case, make sure your path environment variable has the path to pscpp.

export PATH="${PATH}:${HOME}/.local/bin"

Next you’ll need Node.js and NPM. I like to manage Node with NVM

git clone https://github.com/lettier/lambda-lantern.git
cd lambda-lantern/
nvm install `cat .nvmrc` && nvm use

but feel free to use a method you enjoy. Install purescript and psc-package like so.

npm install -g purescript psc-package-bin-simple

PSN can generate a convenient Makefile that manages the build process. If the Makefile is not already present, go ahead and generate that now.

pscpp --makefile

Lambda Lantern already has a package set for psc-package. I couldn’t use the more common one since I needed to include Andy Arvanitis’ fork of the prelude. The fork doesn’t rely on the unsafe, implicit-type equality comparisons. There is a PR open for that so hopefully it’ll be merged soon. Anyway, you can always develop your own package set with psc-package init and pointing the psc-package.json file to your local or remote repository containing your packages.json file.

Once you have your psc-package.json file ready, install the PureScript dependencies with the following command.

psc-package install

If you look under .psc-package/ you’ll see all of the PureScript code for your package set but you’re still missing the C++ FFI (foreign function interface).

Create a ffi/ directory in the main directory of your project. The Makefile that you generated earlier is configured to look there. You can of course change it but be sure to update the Makefile.

Clone the contents of purescript-native-ffi into the ffi/ directory.

⚠️ For Lambda Lantern, when cloning the FFI repository, be careful not to overwrite any existing files as I had to make some modifications and additions.

git clone \
https://github.com/andyarvanitis/purescript-native-ffi.git
cp -nr purescript-native-ffi/. lambda-lantern/ffi/

Your project directory should look something like this.

/
.psc-package/
ffi/
node_modules/
src/
Main.purs
Makefile
psc-package.json

At this point you’ll want to install the game engine or C++ code you’ll be binding to. For Lambda Lantern, you’ll need to install Panda3D. Panda3D has installers for Linux, macOS, and Windows.

Picking the Game Engine

After reviewing quite a few C++ based game engines, I decided on Panda3D as it had the most straightforward API to bind to. Panda3D comes with most of the functionality you see elsewhere like shaders, particle effects, physics, multiple render targets, 3D audio, etc. Still, if you prefer another engine then feel free to use that one instead. The basic idea to this guide is still applicable.

A common pattern I noticed in a lot of the engines I reviewed was the need to inherit from some application class. The game engine controls main and calls your filled out application class Hollywood principle style. Having the engine control main would make for an awkward C++ and PureScript sandwich — starting and ending in C++ with the PureScript game logic in the middle. You could, I’m sure, deconstruct the application class and replicate what the engine is doing in main but with Panda3D available, I didn’t see the need.

The documentation for Panda3D is extensive but sometimes I had to dive into the code to find this or that class that didn’t show up in the C++ reference. The forums are also a great resource with over a decade of posts to search through. Sometimes you’ll come across outdated solutions so I recommend sorting by recent.

Creating the C++ FFI Exports

Here is a bare-bones PSN C++ FFI file.

#include "purescript.h"
FOREIGN_BEGIN(SomeModuleName)
FOREIGN_END

Between the foreign begin and end is where you define the exports that are called from the PureScript side.

exports["id"] = [](const boxed& param_) -> boxed {
return param_;
};

To shuttle around parameters and return values, PSN uses the class boxed. This boxed class builds off of shared_ptr.

To work with a boxed value, you’ll have to unbox it, providing its type.

auto param = unbox<type>(param_);

⚠️ If you don’t know the type then you’re stuck. This is the issue with the implicit-type equality comparisons since the boxed values have their types erased.

💡 All exports have to accept boxed values and return boxed values. Some types like string, char, double, int, long, etc. can be returned directly. The compiler will construct a boxed value for you since the boxed class comes with a few convenient constructors.

exports["echo"] = [](const boxed& s_) -> boxed {
const auto s = unbox<string>(s_);
return s;
};

For other types, you’ll have to call the box procedure to box up your type.

exports["someFunction"] = [](const boxed& param_) -> boxed {
auto param = unbox<Type>(param_);
// ...
return box<Type>(param);
};

You’ll need to curry your functions or in other words, return a new lambda function for each parameter. If your function returns an Effect, you’ll need an additional lambda function that takes no parameters.

In PureScript:

foreign import add1  :: Number -> Number
foreign import add1' :: Number -> Effect Number

In C++:

exports["add1"] = [](const boxed& n_) -> boxed {
const auto n = unbox<double>(n_);
return n + 1.0;
};
exports["add1'"] = [](const boxed& n_) -> boxed {
const auto n = unbox<double>(n_);
return [=]() -> boxed {
return n + 1.0;
};
};

Marking some import with Effect is up to you but generally if the function alters anything outside of its scope or returns different output for the same input, it should use Effect.

For those not familar with the syntax up above, PSN exports use C++ lambda functions.

[ ] // means don't capture any external variables.
[=] // means capture external variables by value.
[&] // means capture external variables by reference.

By value means make a duplicate copy of it so any edits to the copy won’t affect the original and by reference means make an alias such that any edits will affect the original. Ideally, you’ll want to use [=] instead of [&] so you don’t alter the input parameters.

Here’s some typical C++ lambda function patterns.

[&capture,=list](p,a,r,a,m,s) -> ReturnType { body; }
[&capture,=list](p,a,r,a,m,s) { body; }
[&capture,=list] { body; }

I believe the best practice is to unbox your parameters as you receive them and use [=] for each returned lambda function like this.

[](const boxed& param_) -> boxed {
const auto param = unbox<type>(param_);
  return [=](const boxed& param1_) -> boxed {
const auto param1 = unbox<type>(param1_);
    return [=](const boxed& param2_) -> boxed {
const auto param2 = unbox<type>(param2_);
        return ...
};
};
};

However, for certain exports, I had to unbox the parameters in the last lambda function only. For example, like this. Unboxing in the first lambda and then copying by reference like this caused a segfault. And I couldn’t unbox in the first lambda and then copy by value like this as the compiler would complain that I was trying to change a constant NodePath with set_scale. There was the option to use the mutable keyword like this but unboxing all the parameters in the last lambda function worked fine.

Another route would’ve been to dynamically allocate node paths —

// ...
return [&]() -> boxed {
// ...
const auto nodePathPtr =
std::make_shared<NodePath>(
nodePath.find(query)
);
return nodePathPtr;
};
// ...

returning and accepting pointers to them — but this would’ve added unnecessary overhead. Still, with pointers, you can keep them constant, unbox them as soon as you receive them, and copy them by value using [=].

Generally, I stayed close to the Panda3D API. Ideally you want your exports to be very small and basic — putting any extra logic on the PureScript side for added protection. However, in some cases, I deviated when the procedures were routine and repetitive.

Binding to some of the Panda3D API wasn’t the only FFI needed. There was also the need to create FFI exports for commonplace functionality like now, JS centric functions like setInterval, requestAnimationFrame, etc., and looking up system environment variables — something completely foreign to JavaScript (in the browser at least).

💡 Most of the PureScript ecosystem assumes a JavaScript back end which is understandable. Hopefully though, PSN usage grows — increasing with it the C++ FFI coverage. Being able to easily target both the web and native platforms with just PureScript is definitely ideal.

One particular FFI call was for the browser window, which wasn’t applicable, so I filled it out with a no-op.

exports["window"] = []() -> boxed { return boxed(); };

⚠️ Note that the PSN run time will throw an error if it can’t find the FFI export for some FFI call. If this happens, you’ll see something like the following.

terminate called after throwing an instance of
'std::runtime_error'
what():  dictionary key "setInterval" not found

Obviously it would be great if this was caught at compile time but that may not be feasible.

I knew I wanted to use functional reactive programming (FRP) so I needed purescript-behaviors and purescript-event. The former uses requestAnimationFrame while the later uses clearInterval and setInterval.

Counting down the days to the end of the game jam, I decided to go with a simple multi-threaded model to emulate these JS centric functions in C++. Dealing with multiple threads versus JavaScript’s singled threaded nature, there were some subtle and not so subtle differences to my FRP events.

In JavaScript this

setInterval
( function () { while (true) { console.log(1); } }
, 1000
);
setInterval
( function () { while (true) { console.log(2); } }
, 1000
);

would only ever print 1 over and over, never giving the second interval a chance. But in C++ (using my FFI) something like this

setInterval
( [] { while (true) { std::cout << 1 << "\n"; } }
, 1000
);
setInterval
( [] { while (true) { std::cout << 2 << "\n"; } }
, 1000
);

would print 1 and 2 over and over again concurrently.

I plan to return to the multi-threaded model and convert it to a single-threaded, non-preemptive priority queue scheduling model to be more like its JavaScript counterpart.

Creating the PureScript FFI Imports

The PureScript side of the FFI fence wasn’t all that eventful. Having more time, I would’ve created typeclasses to emulate the class hierarchies found in Panda3D. Now that the game jam is over, I can go through and tidy up the interfaces abstracting out the commonalities.

Creating the PureScript FFI calls for C++ is the same as it is for JavaScript.

Here’s an example FFI call to create an ambient light.

foreign import data PandaNode :: Type
-- ...
foreign import createAmbientLight
:: String
-> Number
-> Number
-> Number
-> Effect PandaNode

This import will call its C++ FFI export counterpart.

Looking at the pscpp generated C++, you can see how this is eventually hooked together.

auto createAmbientLight() -> const boxed& {
static const boxed _ =
foreign().at("createAmbientLight");
return _;
};
// ...
boxed v27 =
Panda3d::createAmbientLight
()
("ambientLight")
(0.125)
(0.122)
(0.184)
();

Building the Project

💡 PSN uses the purs compiler (v0.12.0) so there isn’t any difference — PureScript wise — between the C++ and JavaScript targets.

Here’s the general outline to the build process.

  • Compile the PureScript code by calling purs and outputting something called “CoreFn” which is the AST (abstract syntax tree) representation.
  • Compile the CoreFn by calling pscpp and outputting C++.
  • Compile and link the C++ by calling something like g++ and outputting the final executable.

Using the generated Makefile makes the build process super easy. It allows you to pass compiler and linker flags if needed. Here’s the make command I use to build Lambda Lantern

make \
CXXFLAGS=" \
-fmax-errors=1 \
-I/usr/include/python \
-I/usr/include/panda3d \
-I/usr/include/freetype2" \
LDFLAGS=" \
-L/usr/lib/panda3d \
-lp3framework \
-lpanda \
-lpandafx \
-lpandaexpress \
-lp3dtoolconfig \
-lp3dtool \
-lp3pystub \
-lp3direct \
-pthread \
-lpthread"

Most of the flags are to include the Panda3D headers and link against the Panda3D libraries.

⚠️ /usr/include/python, /usr/include/panda3d, /usr/lib/panda3d, etc. may not be the exact paths for your system. You may also need additional flags not listed up above. For instance, on Ubuntu I have to include -I/usr/include/eigen3 .

After you build your project, the executable will be located in ./output/bin/.

Distributing the Project

PSN is cross platform between Linux, macOS, and Windows. For this section, I’ll cover what I did to distribute Lambda Lantern for Linux.

Having created snaps, flatpaks, AUR packages, and AppImages — I find AppImage to be the best experience for both distributor and user.

To start, you’ll want to find out what libraries your binary relies on.

objdump -p output/bin/main

Look for the “Dynamic Section” in the output.

Dynamic Section:
NEEDED libp3framework.so.1.10
NEEDED libpanda.so.1.10
...

⚠️ You may need additional libraries not listed. And even if you manged to include every single library, your AppImage may not run on older distributions depending on how up-to-date your build environment is (usually due to libc). I recommend using Ubuntu 14.04 or 16.04 as your build environment and then testing your AppImage on as many fresh installations as you can. If you missed any libraries, you’ll receive an error — telling you which library cannot be found — like the following.

error while loading shared libraries: libpanda.so.1.10: cannot open shared object file: No such file or directory

The nice thing about AppImage is you can include as many or as little libraries as you want. Ideally you’d include any library that isn’t typically found on the average system. This will give your AppImage the greatest chance of working out of the box.

Here’s the directory tree for the Lambda Lantern AppImage.

/
etc/
Confauto.prc
Config.prc
usr/
lib/
panda3d/
share/
applications/
com.lettier.lambda-lantern.desktop
icons/hicolor/256x256/
com.lettier.lambda-lantern.png
lambda-lantern/
assets/
eggs/
fonts/
music/
sounds/
licenses/
metainfo/
com.lettier.lambda-lantern.appdata.xml
AppRun

The Config.prc and Confauto.prc files are required for Panda3D to run. In the AppRun file, I provided the path to the prc files and to Lambda Lantern’s assets directory. If you recall, I had to create a FFI export/import for looking up environment variables. At the beginning of the program, Lambda Lantern looks up an environment variable to find its assets.

export LAMBDA_LANTERN_ASSETS_PATH=\
"${HERE}/usr/share/lambda-lantern/assets"
export PANDA_PRC_DIR="${HERE}/etc"

With all of the files in place and the AppRun file filled out, you create the AppImage like so.

appimagetool-x86_64.AppImage lambda-lantern.AppDir

The AppImage tool will perform some validation and then generate the AppImage if everything checks out.

If you’re using GitHub, you can upload the AppImage to a release for users to download. After downloading, users can mark the AppImage as executable and start playing! 🎉

Final Thoughts

Binding Haskell to C is fairly easy but not so with C++. This is unfortunate as as some of the widely used libraries for game development are typically in C++. For awhile I tried various different methods for binding Haskell to C++ but it was never as straightforward as binding PureScript to C++ using PSN. If you’ve found a great way of binding Haskell to complex C++ projects do let me know. However, if you’ve been trying to bind Haskell to C++ — not finding any great options — strongly consider PSN.

Being able to bind PureScript to C++ opens up so many possibilities since there are so many great C++ based projects out there. From cutting edge game engines to robust machine learning libraries. Picking any one of them and gluing on top of it a PureScript interface gives you some distinct advantages.

Choosing Panda3D, I believe, is the right way forward. It doesn’t try to control the workflow — from concept to executable — making it perfect for me as I enjoy walking untraveled paths, seeing what’s possible like I did with the Gifcurry and Movie Monad projects. Originally, I wanted to use Godot but I needed it to be something I’d plug in instead of an environment that you augment from inside. If you know of a game that controls main and uses Godot with only C++, please let me know. Anyway, it’s easy to brush Panda3D aside as a non-started, if you only view the site, but if you dig a little deeper — you’ll find some great projects using it like the one below.

Hopefully others will use PSN more and more. I’m very excited to turn the concept behind Lambda Lantern into a full featured game but I’m even more excited about using it to get the word out and build up the ecosystem around PSN. In my opinion, being able to target both the web (JavaScript) and desktop (C++) with ease — using only PureScript— is a dream come true. 👍