Help me to adjust a classic snake game design for a Rust implementation

Here we are, yet another one of those boring "first Rust project" question, but I feel like I should start from "somewhere" and that "somewhere" is probably here.

If you would ask me to design you a solution for the classic snake game (with discrete tiles), using GRASP patterns, SOLID patterns, GOF patterns and what not, I would produce you a UML model like this, which I could trivially implement in any OOP langauage such as Java, Python, C++, ecc...

But this apparently trivial design poses some nasty borrowing problem in a Rust implementation.
I'm not asking you to give me a Rust implementation in the answer but to explain me how should i handle the borrowings here, or how should i edit my design if a safe implementation is not possible.

Now, before moving on, I can hear you scream from here that this is probably a XY problem and if you're thinking "Dude all this desgin stuff is simply an overkill! I could implement snake in rust in 300 LOC with way better performance than you will ever achieve" you're probably right, but to designing application like this means also to be able to handle upredictable, growing complexities without a complete rewriting of the implementation.

"Where in the hell would you find upredictable, growing complexities in your personal classic snake game project?" you would ask, and i would answer you nowhere! But bear in mind that the class diagrams proposed in this question isn't something crazy and contains constuct used almost in any OOP project in my experience.

The first problem is that the Snake object will send messages to the Board object during it's lifetime, so it will need a reference to it, but the SnakeGame class holds both the Snake and the Board instances so creating a Snake object in its contructor is non trivial.
I could encapsuate the Board object in a Rc and clone it in the constructor or encapsulate the Snake in an Option and using a second operation init() to actually initializa the Snake object, but both the solution have easily noticeable drawbacks.

The single squares in the Board not having a defined size must me enclosed in a Box or in a Rc, but when the Snake object calls the polymorphic on_collision() method the naked Square, stripped by the Box or Rc won't be able to call back the move_into() or grow_into() Snake's methods wich obviously need the right Box or Rc to operate.

There are way more details and problems but I will stop here hoping to not being too wordy, as stated before I would like your tought and guidance on how to implement this design or how to edit the design in a "Rusty" way. Finding these difficulties makes me wonder if designing complex application in Rust would require a paradigm change on the way i design software.

Unfortunately, games are a complicated ball of state, and complicated balls of state aren't easy to map into Rust. UI also suffers from the same problem.

One big thing here is that rather than using an interface and multiple subclasses for squares, in Rust you'd typically use an enum. In Rust, enums can have associated state and behaviors, and are similar to how you'd use sealed/closed interfaces (ones where you locally know the full set of implementers) in a more OOP design.

More generally, games implemented in Rust tend to strongly favor an Entity Component System design. bevy is a currently popular, active, and growing engine which runs on an ECS.

The core thing which makes ECS designs fit Rust well isn't really the ECS, though; ECS just makes the pattern fast. Instead of storing regular references to other objects, you store handles, which are some sort of Copy key, typically an integer. Then, all of your functionality takes some &World context, and you can ask the world to give turn your handle into a reference to work with.

If you wanted to do this without an ECS design, you'd likely have the world own each actor in the world in some list, with each wrapped in an RwLock, with handle resolution returning the lock guard. There's the risk of deadlocks or panics if you try to get some &mut lock twice at the same time; resolving that is a big part of the smarts in bevy's ECS is doing.

Another trick is using channels to pass messages between actors if the message doesn't have to be handled right now. This decouples the actors and is generally a good idea even in more OOP designs where it might be accomplished with delegate subscriptions and other pub/sub solutions.

But also, in general, don't shy away from using Rc and locks. Rust is making the costs explicit rather than baking them in as part of the language such that you can choose to avoid them, but using them is still perfectly acceptable.

A useful thing when using Rc a lot in something like this is Rc::new_cyclic. It allows you to get a Weak handle during construction of the value which will be managed by that Rc.

An important one is that you should take e.g. &dyn Square instead of Rc<dyn Square> if you don't need to keep ownership, and only need to use it for the duration of that method.

This ends up making a lot of things which are implicit in a traditional OOP design a lot more explicit. This does take more effort to structure, but will typically lead to a system with a much clearer view of ownership and the lifecycle of values.

Bonus tip: if things live until the end of the program anyway, you can Box::leak them and deal in &'static references. The limitation is that what you leaked won't ever be dropped, but if its lifetime is essentially global anyway, this often doesn't actually matter all that much.

1 Like

One possibility here is to make "message" mean actually "put an object into a queue for delivery later", rather than it being a synonym for "call a function".

Imagine what it would take to run multiple snakes in parallel on the same board, for example. The same kinds of abstractions that make that possible also tend to solve the ownership issues that you hit in Rust.

4 Likes

I would like to second everything CAD97 said, it's pretty tricky to model any sort of graph in Rust. One good way to model these graphs is by having the parents own the children, and have the children pass messages to the parents to allow children to affect them. The most simple way is to pass messages via the return type, but you can get fancy with channels and stuff too. Going to the grid, you might have

struct Snake(Vec<(usize, usize)>);

struct Board{
    grid: Vec<Vec<Box<dyn Square>>>, //or whatever grid models your problem best, maybe use an enum instead of a trait object, or use an `Rc` if shared ownership is desirable
    snake: Snake,
}
enum CollideEvent{
    Die,
    Grow,
    Move
}
trait Square{
    fn on_collide(&self) -> CollideEvent;
}
struct EmptySquare;
impl Square for EmptySquare{
    fn on_collide(&self) -> CollideEvent{
        CollideEvent::Move
    }
}
// and so on

Then, after handling collision resolution, the Board struct can easily ask the appropriate square what it needs to do, and then delegate to the appropriate method of Snake. Assuming you pass the square by index and the grid by reference, the snake is then free to mutate things as needed. Something like

impl Board{
    fn update(&mut Self, dir: Direction){
        let square = self.get_adjacent_square(snake.head_coordinates(), dir);
        let event = square.on_collide();
        match event{
            CollideEvent::Move => self.snake.move(dir, &mut self.grid),
            _ => todo!(),
        }
    //obviously, plenty more logic is necessary here
    }

Remember, just because one object needs to refer to another does not mean it actually needs a reference to that type. Holding on to an index, or having shared state passed in when needed via function arguments are both easy ways to avoid the complications that an actual reference would entail.

Another option is to model any parent-child relationship with an Rc<RefCell<T>>, and child-parent relationships with a Weak<RefCell<T>> to avoid reference cycles. Also, contrary to what CAD97 said, you should not ever have an Rc<RwLock<T>>. It's needlessly expensive in the single-threaded case and entirely useless in the multithreaded one. Use an Arc<RwLock<T>> if thread safety is a concern, and Rc<RefCell<T>> if it isn't.

Of course, if all your objects are homogenous types (eg, Box<dyn Entity> or something) you can always use an actual graph to model all your complicated relationships without needing to worry about avoiding reference cycles or other problems like that.

(ah, whoops, I was just referring to the concepts rather than the specific types. RefCell is still a read-write lock, just a single threaded one rather than a threadsafe one.)