Skip to content

Instantly share code, notes, and snippets.

@dadleyy
Last active October 31, 2022 19:29
Show Gist options
  • Save dadleyy/edc6ead991f363764fc5f1a3a47fb630 to your computer and use it in GitHub Desktop.
Save dadleyy/edc6ead991f363764fc5f1a3a47fb630 to your computer and use it in GitHub Desktop.
inspiration

Inspiration

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.

Background; fluff

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.

Educational Goals & A "move" to the XIAO ESP32C3

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.

C++17 && Move Semantics

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++:

  1. Modern C++ Design: Generic Programming and Design Patterns Applied (Andrei Alexandrescu) [amazon]
  2. 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:

  1. creating an instance as a copy of another
  2. creating an instance from the "death" of another
  3. copying one instance into another
  4. 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.

C++17 && variant

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:

  1. 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.
  2. 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 actually private classes within the Obstacle 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!

← README


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment