Should I use Result, debug_assert, or assert for my game's internal mutation functions?

I'm making a card game in Rust. I have a number of functions which perform standard mutations on the ongoing game's state, like spend_mana() for example. This function has a precondition that the player has sufficient mana available before it is invoked. It's a low-level helper function with around 200 callsites. It is not generally possible to enforce the precondition at compile time.

I'm trying to decide how I should handle preconditions for this kind of function (there are several dozen similar ones). Should I:

  • Return an Err if the precondition is violated?
  • assert!() that the precondition is met?
  • debug_assert! that the precondition is met?
  • Silently ignore failures, e.g. via saturating_sub?

I assume goes without saying, but I really do not want the production version of the game to crash if there are literally any other options.

In general, if the violation of a precondition can only happen due to an internal inconsistency/bug, then you are expected to assert!() or debug_assert!() it. (If you go down this path, you must use assert!() and not debug_assert!() if violation of the precondition can potentially lead to unsoundness or UB.)

However:

This suggests that you want to treat internal game engine bugs as non-fatal. In this case, you should probably not have assertions fire, but use Result and propagate errors as usual. Then, in the main loop of the game, inform the player that a bug happened, and provide some graceful recovery mechanism (e.g., perform an automatic save and return the player to the beginning of the current level).

From the little information you gave I would use Result. I guess you'd anyway have to check at some point if funds are sufficient. Why not do this in the spend_mana() function and return an error when funds are not sufficient.

Why not do this in the spend_mana() function and return an error when funds are not sufficient.

I don't know if this applies to @thurn's game, but in general, a move in the game might require spending multiple resources (or otherwise changing the state of multiple entities), in which case you want to return an error before any of them is spent, rather than leave the game in an impossible state.

One could imagine having some system of Drop-guards that arranges so that all such effects are undone unless explicitly committed, but that isn't necessarily going to be the best program structure.


In my own game, I solve this problem with a Transaction trait which provides two operations (slightly simplified):

fn check(&self, target: &T) -> Result<(), PreconditionError>;
fn commit(&self, target: &mut T);

commit() may panic or make nonsensical changes if it is called without a successful check; in order to make this unlikely, the normal way to use a transaction (possibly composed from other transactions) is to call a function that calls check() followed by commit() rather than calling the primitive operations directly.


As to the original question of best error handling — I think that passing around Result makes sense for situations that are like “I didn't know you could even try to do that” — the code ‘understands’ the nature of the problem, but is just ‘surprised it was possible’.

However, not everything is so clearly defined, and even if you use lots of Result, there will almost always be possible panics anyway, and so you should also make use of catch_unwind() and/or inspecting the result of thread joining, in the game application code, to recover from panics occurring in the game logic.

In the event of such a panic, you don't want to keep using your current game state, because it might be corrupted (either half-edited due to panicking during an update, or just already corrupted so as to lead to the panic) — possibly even producing a soft-lock, which you don't want to give your players. Instead, discard all the game state and recover from a saved copy (e.g. in a card game, you might save every time a turn ends).

Many people will tell you that catch_unwind should not be relied on because code can be compiled with panic=abort. This is an important consideration for libraries. For applications, you get to choose to not do that and use panic=unwind (which is the default anyway).

This still doesn't mean all panics can be caught, though, because a panic while panicking is always an abort — but it's very unlikely that a double panic happens as a result of game logic if you're not getting too clever with your code. So, don't rely on catching as a solution for actually not stopping — in a server/embedded type of situation you want to have an external watchdog that restarts the process, anyway — but it is a fine additional protection for a game.

3 Likes

IMHO, if a bug is particularly likely to appear in the code you are concerned with, return a Result because it gives you a convenient way to add extra context to the error. There is a point at which returning a Result for every potential fallible operation is just overkill that will add unnecessary boilerplate that you will spend time maintaining for no good reason.

For handling the rare panic (which is still likely due to common mistakes like indexing out of bounds or using libraries with panics that you cannot control) you could consider adding a crash reporter. Something similar to what Firefox has: Crash Reporter — Firefox Source Docs documentation (mozilla.org)

This is just a way to say you probably should not concern yourself with every possible panic. There are other ways to handle them gracefully. And they are bugs that you need to fix anyway, regardless of whether you are threading Result through all of your code or just catching unwinding panics at the top level. My personal preference is making a judgement call on the likelihood of a bug and letting them panic if they are not expected to happen.

2 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.