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.