Skip to content

Instantly share code, notes, and snippets.

@perky
Last active April 23, 2023 21:38
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save perky/f6f84f417170de6228f49912a23627dc to your computer and use it in GitHub Desktop.
Save perky/f6f84f417170de6228f49912a23627dc to your computer and use it in GitHub Desktop.
Mario State Machine. Showcases the usefulness of switches and tagged unions in Zig.
//! ======
//! file: mario_state_machine.zig
//! This is an example of a Mario/Powerup state machine.
//! It showcases the usefulness of switches and tagged unions in Zig.
//! See state machine diagram:
//! https://external-preview.redd.it/TgwKB-bdEWJase06sIDXmVtaGaP7AZTD9YKn0x4yUWo.png?auto=webp&s=c0318b178038bd83212392c8fdd16e1a4b1a0049
//! ======
/// This is a tagged union.
/// See tagged union doc:
/// https://ziglang.org/documentation/master/#Tagged-union
/// Each union "tag" becomes part of an enumeration, and it means we can use the union in a switch expression.
/// Combined with Zig's exhaustive switches, this becomes a very useful pattern!
///
/// A union can only have a single "tag" active at any time. Attempting to read from an inactive tag throws a compile
/// error, making them a good tool for implementing state machines.
pub const MarioMode = union(enum) {
dead: void, // void tag type makes it just like a basic enumeration.
mario: void,
super: SuperMario,
cape: CapeMario,
fire: FireMario,
/// We could have had MarioMode as a basic enum, but the tagged union allows us to attach different data payloads
/// for each "tag". Note unlike C unions, the in-memory representation isn't guaranteed. For that you use
/// "extern union" or "packed union".
pub const FireMario = struct {
fireballs: u8,
/// Putting functions inside a struct/enum/union is an organization tool, effectively namespacing the function.
/// We also get some syntatic sugar with the first parameter, effectively making it a method.
pub fn fire(self: *FireMario) u8 {
if (self.fireballs == 0) unreachable; // The program will panic if execution reaches "unreachable".
// Without this we would still get an integer overflow panic
// if it attempted to subtract 1 from 0. This just states your
// intentions more explicitly.
self.fireballs -= 1;
return self.fireballs;
}
};
/// Our payload structs only have a single field each, so the tag types could have used f32/i32 directly, but you
/// could imagine how over development iterations the payloads could end up with more complicated data.
pub const CapeMario = struct {
duration_left: f32
};
pub const SuperMario = struct {
bonus_health: i32
};
// You can also declare constants within an union/struct/enum,
// another useful namespacing tool.
pub const super_default = MarioMode{ .super = .{ .bonus_health = 16 } };
pub const cape_default = MarioMode{ .cape = .{ .duration_left = 4.5 } };
pub const fire_default = MarioMode{ .fire = .{ .fireballs = 10 } };
/// This is the state machine. Select the next mode, based on the current mode and "transition" (the powerup).
/// The switch statements here are exhaustive, so if you add new tags to either MarioMode or MarioPowerup you will
/// get a compile error if you do not also add switch cases.
/// See switch doc:
/// https://ziglang.org/documentation/master/#switch
///
/// This makes switch expressions much more useful than in other languages, where the danger of using the
/// enum+switch paradigm could have programmers easily add new enumeration tags and forget to update switches
/// scattered across the codebase.
///
/// Note the return type is ?MarioMode, this states that this function will either return MarioMode or null.
/// See Optionals doc:
/// https://ziglang.org/documentation/master/#Optionals
pub fn nextMode(mode: MarioMode, powerup: MarioPowerup) ?MarioMode {
// Switches can be used as expressions, making them even more useful.
return switch(mode) {
.dead => null,
.mario => switch(powerup) {
.mushroom => super_default,
.feather => cape_default,
.flower => fire_default
},
.super => switch(powerup) {
.mushroom => null,
.flower => fire_default,
.feather => cape_default
},
.cape => switch(powerup) {
.mushroom, .feather => null,
.flower => fire_default
},
.fire => switch(powerup) {
.mushroom, .flower => null,
.feather => cape_default
}
};
}
};
/// A basic enum.
/// This will be backed by an unsigned 8-bit integer. You could omit the (u8) to have the compiler infer the backing
/// type.
/// See enum doc:
/// https://ziglang.org/documentation/master/#enum
pub const MarioPowerup = enum(u8) {
mushroom,
feather,
flower,
};
pub const MarioCharacter = struct {
mode: MarioMode = .mario,
health: i32,
/// Note the "*const" parameter here, this is a pointer to immutable data. Pointers in zig are guarenteed to not be
/// null, so we don't need to check for nullptr here. Nullable pointers are expressed as "?*".
pub fn getHealth(self: *const MarioCharacter) i32 {
return switch(self.mode) {
// Switching on a tagged union lets you then capture the tag's payload. The capture is expressed in
// "|mode_state|" and gives us const data. For mutable data you would express it as "|*mode_state|".
.super => |mode_state| self.health + mode_state.bonus_health,
// The "else" case is called for all other tags not counted for, this satisfies the constraint that all
// switches must be "exhaustive".
else => self.health
};
}
pub fn givePowerup(character: *MarioCharacter, powerup: MarioPowerup) void {
const next_mode_or_null: ?MarioMode = character.mode.nextMode(powerup);
// This is the typical way to handle Optionals. If the Optional is not null, the actual data will be captured
// into the following scope.
if (next_mode_or_null) |next_mode| {
character.mode = next_mode;
}
}
pub fn useSpecialPower(character: *MarioCharacter) bool {
switch(character.mode) {
// This is capturing the union payload as a mutable pointer. "state.fire()" is syntatic sugar for
// "MarioMode.FireMario.fire(state)".
.fire => |*state| {
if (state.fire() == 0) {
character.mode = .mario;
}
return true;
},
else => return false
}
}
};
// the "std" (standard) zig library has a lot of useful and common data structures and functions. This is cherrypicking
// the assert function out of it.
const assert = @import("std").debug.assert;
// Zig comes testing system for writing and running unit-tests. You can run all tests in this file on the command line
// via:
// zig test mario_state_machine.zig.
// The std library also has useful functions under std.testing.
// See testing doc:
// https://ziglang.org/documentation/master/#Zig-Test
test "mario state machine" {
var mario = MarioCharacter{ .health = 100 };
assert(mario.mode == .mario);
assert(mario.getHealth() == 100);
// "mario.givePowerup(.mushroom)" is syntatic sugar for "MarioCharacter.givePowerup(&mario, .mushroom)".
mario.givePowerup(.mushroom);
assert(mario.mode == .super);
assert(mario.getHealth() == 116);
mario.givePowerup(.flower);
// Return values are explicit, if you don't intend to use them you must explicitly discard them with "_".
_ = mario.useSpecialPower();
const did_fireball = mario.useSpecialPower();
assert(mario.mode == .fire);
assert(mario.getHealth() == 100);
assert(did_fireball);
assert(mario.mode.fire.fireballs == 8);
for (0..7) |_| {
_ = mario.useSpecialPower();
}
assert(mario.mode == .fire);
assert(mario.mode.fire.fireballs == 1);
_ = mario.useSpecialPower();
assert(mario.mode == .mario);
assert(mario.useSpecialPower() == false);
mario.givePowerup(.feather);
assert(mario.mode == .cape);
mario.givePowerup(.feather);
assert(mario.mode == .cape);
mario.givePowerup(.mushroom);
assert(mario.mode == .cape);
mario.givePowerup(.flower);
assert(mario.mode == .fire);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment