This project is originally inspired by the line wobbler game by Robin Baumgarten. The game is available to play for free at the wonderville arcade bar in nyc. Robin's version is inspiring; it provides players with a fascinating gameplay experience built from some - subjectively - simple components. If you are ever around the Brooklyn NY area, both the bar and the game are definitely worth a shot.
This part may be boring to most.
Having visited from NYC around the time I was working on my orient beetle project, I had some previous experience playing with the ESP32 family of microcontrollers while I was taking in the simplicity and craftmanship of Robin's game. At this point, I had a cursory level of experience writing arduino code, flashing a microcontroller using platformio and the esp toolchain, and a general knowledge of microcontroller/peripheral engineering.
So, when I got back home from my trip to NYC, I pulled out one of the extra Firebeetle dev boards that I was using previously and figured I'd give it a shot. Being a compulsive purchaser of adafruit products, I also had happened to be sitting on a few thumbstick, pushbutton, and led light strip hardware pieces. The initial prototype came together relatively quickly, starting with a simple controller implementation that read the analog input from the thumbstick and the digital signal from the pushbutton, with an LED strip being "rendered" based on the user input.
Going into this project, I knew that I wanted to take advantage of the nifty features provided by the esp32, and
this is where I started to put my own flavor on Robin's game - I would have the controller be a completely separate
physical device from the LED strip. Having seen it mentioned around the /r/esp32
subreddit a few times, I figured
I might have a good shot using the esp-now feature of the controller - server communication mechanism. With a few
tweaks to the single-device code, and a superrr rough message schema/serialization/deserialization layer, I was
able use one controller to send input state to another, where the second would update the LED strip accordingly.
The project concept was proven.
Why the xiao esp32-c3?
Being inspired to build a cool, minimalist arcade game is only half the story of this project. That other half,
the more interesting half, was the hope to dive into embedded microcontroller programming world using the rust
language. Now, if you've peeked at the code so far (as of bd24fe4
), you'll have noticed there there is not a
single line of code written in rust.
Though I continue to maintain a significant degree of optimism towards rust's future in the embedded world, the support for the esp-now communication protocol does not seem to "be there" yet. There is tons of active development happening in the github esp-rs organization, and I am very grateful to the engineers working on that project.
However, despite not having support for the protocol that I'd be relying on in my rust implementation, it does appear that the esp32c3 chip - which is built on the risc-v architecture - does have good rust support. To quote the esp32 rust embedded book:
The RISC-V architecture has support in the mainline Rust compiler so setup is relatively simple, all we must do is add the appropriate compilation target.
With that in mind, I actually changed the hardware I was building this project on: if I couldn't write the code in rust, at least I could write code on a chip that was supported by rust. After all, rewriting code isn't really the hard part that we should be "afraid" of - its learning about our business domain to model it correctly. This is when I found the xiao esp32c3. At around ~$5.00 us dollars on digikey, this controller board was even cheaper than the Firebeetle devices I had been using previously.
With some fresh boards delivered from digikey and some minor tweaks to my platformio.ini
files, the project was
ported over to a rust-hospitable environment.
No rust? Write rusty-c++.
Without being immediately able to write the entire codebase in rust, I figured I'd see if I could implement all of the game logic in as-close-to-rust of a style as I could. I wasn't concerned with c++ best practices, or other people's opinions on object orient programming. I wanted to fail my way, and learn mistakes firsthand. At the same time, I also wanted to write very "functional" c++: I wanted to avoid mutation and side effects. This isn't necessarily something that is inherent to rust, but I had previously built a few things in Elm, and had an absolute blast writing that code.
So where to start? The first thing I wanted to play with was c++ move semantics. Back in 2015, as I was building a rather "ambitious" (considering my lack of experience at the time) c++ application, I had read two books that I consider to have been particularly helpful in helping me establish a more... "fundamental" understanding of c++:
- Modern C++ Design: Generic Programming and Design Patterns Applied (Andrei Alexandrescu) [amazon]
- Advanced C++ Programming Styles and Idioms (James O. Coplien) [amazon]
These books introduced me to the impressive amount of control c++ gives to its users over the construction of its class instances. For folks who may not know, this control includes being able to decide what to do if you are:
- creating an instance as a copy of another
- creating an instance from the "death" of another
- copying one instance into another
- consuming one instance into another
I believe it is popular knowledge in the c++ community (I do not consider myself part of the c++ community, I have too much respect for the folks who have mastered it) that there exists "the rule of five". Generally, the idea is that if you want move semantics, you should be explicit with every way to assign or create instances of your class. This means creating methods for all of the following:
class Level {
public:
Level();
~Level();
Level(const Level& source); // Make a copy of `Level` from something else.
Level& operator=(const Level& source); // Copy the contents of some other `Level` into this level.
// Moves:
Level(const Level&& source); // Consume some other `Level` into a new one.
Level& operator=(const Level&& source) // Consume some other `Level` into _this_ one.
};
Understanding the difference was important to me, because in rust, there are familiarities that I would likely lean on in my rust re-write of my c++ implementation:
impl Level {
fn new() -> Self {
// "default constuctor"
}
fn copy(&self) -> Self {
// "copy constuctor"
// (note: real world would probably `#[derive(Copy)]`, `#[derive(Clone)]`, or `impl Copy` instead).
}
fn consume(self) -> Self {
// "move constuctor"
}
}
The biggest difference here is that the rust ownership model considers the "move constructor" a destructive move. There is a really great article by Jimmy Hartzell here that goes deeper into this subject, but the gist is that while the following c++ code is safe, the following rust code is not:
Level level;
Level new_level;
new_level = std::move(level);
level.some_member_fn(); // <- note: we are able to "do things" with
// `level` after it has been moved from.
// This _includes_ doing things with types like
// `std::unique_ptr`.
let level = Level::new();
let new_level = level.consume();
level.some_member_fn() // Rust would error here.
Now, why does moving matter at all, you may be asking? Well again, this project was meant to be a personal educational experiment as much as anything "cool", and part of that was exploring functional c++. Part of exploring functional c++ is to avoid mutation. Now, accomplishing this is quite challenging unless the language you are working in has great support for "lazy" movement like Elm and javascript:
// Javascript
const new_state = { ...old_state, updated_field: updated_value };
--- Elm
update : State -> State
update old_state =
{ old_state | updated_field = updated_value }
What is particularly interesting to me here, is that these language features also take control away from the user to
some degree: at the end of the day, I am not in immediate control of whether or not the javascript runtime is going to
copy string
values from old_state
into new state, or if it would actually move the underlying pointers around so
as to reuse memory. In most cases, that might be fore the best, but it is interesting that I have no ability to say,
create a type that when spread onto another object, it should free up all the memory it used, since I have some
invariant on the type that will ensure it is no longer used.
This is ultimately what amuses me about c++ move semantics: you have total control over what happens there. So a large part of this project was writing move constructors and assignment operators, familiarizing myself with what they do and how you'd write them.
Now, moving without any additional logic isn't very interesting. Taking this pointer from some object and moving it to some other object while clearing the original object's pointer is cool, but it doesn't do anything. So again, why am I so interested in c++ move semantics?
Consider the main update
function at the core of Elm's architecture:
update Message -> Model -> ( Model, Cmd Message )
update msg model =
This function takes some message and a model, and returns a new model. Inherently, everything is immutable in Elm, so we have no alternative to applying some changes to a model: if we want to update it, we need to return a new one.
Given Elm is, at least subjectively from my impression, a straightforwardly functional programming language, I wanted to replicate this function signature: give me a thing, and something that happened, and I will give you a new thing. This is almost exactly the same pattern I implemented in my rust implementation of the casino game craps, which ended up being very pleasant to work with.
So left to explore this in c++, I ended up with class methods that look like:
class Level {
Level update() const && {
return std::move(*this);
}
};
int main() {
Level l;
l.update(); // <- fails, more below.
return 0;
}
One of the more important bits here is that &&
at the end of the method definition. This is known as a
"ref-qualifier" (see: n4239), and tells the compiler that the this
inside of the method must be an
"rvalue". This means that it is up to the caller to make sure we are "moving" from the Level
instance we are
calling this update
method on. If we aren't, the compiler will complain with:
'this' argument to member function 'update' is an lvalue, but function has rvalue ref-qualifier
Instead, we must either be working with a "temporary" Level
, or we need to explicitly move
the level:
int main() {
Level l;
Level().update(); // Fine, `Level()` creates a temporary, rvalue
std::move(l).update(); // Fine, move constructors get involved.
}
Now we have something that is nice - we are able to write a function that operates on an object where the caller must be somewhat aware of the "destructive" nature of our movement, and we have control over how the movement happens. This effectively means that we can performantly control how our mutation happens: we have mutation through movement.
The second "big thing" I wanted to explore during this project was seeing how closely I could write code in c++17 that would look and feel like code written in rust that leverages "data carrying enumerated types". This language feature is probably more commonly called "sum types" or "tagged unions".
Admittedly, it is a bit challenging for me to explain why I love being able to define my own types in languages using these other than that they help make impossible states impossible. What I am positive of, is that they appear in some of my favorite languages:
// rust
enum NetworkRequest {
Pending,
Failed(std::io::Error),
Success(String),
};
-- elm
type NetworkRequest
= Pending
| Failed Error
| Success String
Heck, even typescript has some support for them too:
type NetworkRequest =
| { kind: 'pending' }
| { kind: 'failed', error: Error }
| { kind: 'success', error: string };
Unlike my goal of learning more about c++17 move semantics, wanting to play around with data carrying enumerated types in c++ was something that I didn't necessarily associate with "doing functional programming". Instead, I just wanted to find out how I could design my data structures and types in a way that would avoid classes and inheritance (something I feel is aligned with the "composition over inheritance" philosophy).
The first place I figured I'd start with using an enumerated type was as a union of all the different "npc" types in the game:
class Obstacle {
public:
std::tuple<Obstacle, Command> update(const Message&) const &&;
private:
class Pawn;
class Goal;
class Snake;
using ObstacleKind = std::variant<Pawn, Goal, Snake>;
ObstacleKind _kind;
};
There may be a more ergonomic way of doing this, but as my first pass, I was a bit stuck with just how "adverse" the language was towards the user working with these:
- I felt required to create a wrapping class to hold an inner type. I believe this is necessary to define the common
interface like our
update
method that is shared across all variants. - Constructing the variants of the
std::variant
throught that wrapping class means the constructor of the wrapping class leaks type information. This is probably a consequence of how I chose to manipulate accessibilty modifiers (public
) to better represent the encapsulation I was aiming for, since the "inner types" are actuallyprivate
classes within theObstacle
class itself. It is probably possible to expose these on the public interface, but it wasn't immediately clear to me if that would be better.
Anyways, using this type allowed me to use hold an std::vector<Obstacle>
on my Level
class, without having to
treat them as some inherited, abstract class.
The last bit worth mentioning here is that unlike some of the language support for data carrying enumerated types in my favorite languages:
match (is_mutiple(value, 5), is_multiple(value, 3)) {
(true, false) => Some("buzz"),
(false, true) => Some("fiz"),
(true, true) => Some("fizbuzz"),
_ => Nothing,
}
case ((isMultiple value 5), (isMultiple value 3)) of
(True, False) ->
Just "buzz"
(False, True) ->
Just "fiz"
(True, True) ->
Just "fizbuzz"
_ ->
Nothing
To handle the same responsibility using c++17, I ended up using the std::visit
, which can be used by
defining a class that overloads the ()
operator for each variant of your type:
class Visitor final {
std::tuple<ObstacleKind, FrameMessage> operator()(const Snake&) const;
std::tuple<ObstacleKind, FrameMessage> operator()(const Pawn&) const;
std::tuple<ObstacleKind, FrameMessage> operator()(const Goal&) const;
};
This class is then created each frame with the "shared state" across all variants, and is used to return the new state of our obstacle:
class Obstacle final {
std::tuple<const Obstacle, FrameMessage> frame(uint32_t time, const FrameMessage& input) const && {
// ...
Visitor visitor(/* shared stuff */);
auto [new_kind, message] = std::visit(visitor, std::move(_kind));
// ...
}
};
At this point, I had something I was pretty happy with, so I dumped some code and got it working. Hopefully it won't be long until I can re-write all of this in rust though. Cheers!