Created
June 29, 2026 12:03
-
-
Save perky/398ddd4a187eeb633842bba6799bb32e to your computer and use it in GitHub Desktop.
Perky's Perks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //! This is some code I wrote after watching the GMTK (Game Makers Toolkit) video | |
| //! on how the presenter implemented 'Passive Modifiers' in a word-making game. | |
| //! See: https://www.youtube.com/watch?v=n1cd1FhVAWY | |
| //! | |
| //! In that video he showcases an implementation that uses class inheritance and | |
| //! virtual functions. I thought that's a great concrete example of gameplay code | |
| //! that can be used to show how I think about programming, where I would go the route | |
| //! of tagged unions and switch statements. | |
| //! | |
| //! To be clear, this isn't a 'my way is better' piece. What's presented in GMTK is | |
| //! a perfectly reasonable implementation that makes use of the language features of | |
| //! C# and Unity. Here I'm making use of the language features in Zig. | |
| //! | |
| //! Take some time to read through and understand this code. | |
| //! It's the basic scaffold for scoring words in a hypothetical word game. | |
| //! Later I'll add the logic for handling 'modifiers' that tweak the scoring logic. | |
| // This is the data the scoring function returns. I use a struct as it's going to | |
| // package more data going forward. | |
| const Score = struct { | |
| total_score: i32 = 0, | |
| }; | |
| /// Scores a whole word. | |
| fn scoreWord(word: string) Score { | |
| var score = Score{}; | |
| for (word) |letter| { | |
| score.total_score += scoreLetter(letter); | |
| } | |
| return score; | |
| } | |
| /// Scores a single letter, values taken from Scrabble tile scores. | |
| fn scoreLetter(letter: u8) i32 { | |
| const score: i32 = switch (std.ascii.toUpper(letter)) { | |
| 'A', 'E', 'I', 'O', 'U', 'L', 'N', 'S', 'T', 'R' => 1, | |
| 'D', 'G' => 2, | |
| 'B', 'C', 'M', 'P' => 3, | |
| 'F', 'H', 'V', 'W', 'Y' => 4, | |
| 'K' => 5, | |
| 'J', 'X' => 8, | |
| 'Q', 'Z' => 10, | |
| else => 0, | |
| }; | |
| return score; | |
| } | |
| pub fn main() !void { | |
| // When you see an underscore between brackets [_], | |
| // it means declare an array and infer the size of it. | |
| const input_words = [_]string{ "OKAY", "HELLO", "QUIET", "ARTWORK", "NONE", "OCTET" }; | |
| for (input_words) |word| { | |
| const score = scoreWord(word); | |
| debugPrint("score for '{s}': {d} total\n", .{ word, score.total_score }); | |
| } | |
| } | |
| const std = @import("std"); | |
| const string = []const u8; | |
| const debugPrint = std.debug.print; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //! This version adds a simple modifier that can either add to the score, or multiply the score. | |
| const Modifier = struct { | |
| effect: union(enum) { | |
| none: void, // does absolutely nothing | |
| add_score: i32, // simply adds a flat value | |
| multiply_score: f32, // multiplies the score | |
| }, | |
| }; | |
| const Score = struct { | |
| total_score: i32 = 0, | |
| pre_modifiers_score: i32 = 0, // added some more interesting score information | |
| total_bonus_points: i32 = 0, | |
| total_multiplier: f32 = 1, | |
| }; | |
| /// This now takes an immutable slice (AKA array) of modifiers. | |
| /// The order of modifiers in that array matter, say if you have | |
| /// an add followed by a multiply. | |
| /// | |
| /// Personally I prefer this pattern of passing in the modifier data | |
| /// and using a switch statement, rather than each Modifier being an | |
| /// 'object' which contains it's own method for applying its logic. | |
| /// I like that all the logic for modifiers is in one location. | |
| /// An important consideration here when compared to C# is what happens if | |
| /// I add a new field to the 'effect' union but fail handle that in the switch | |
| /// statement below. In Zig this would not compile, and I would very quickly receive | |
| /// that information needed to fix the issue, whilst in some other languages you may | |
| /// not realise there's a missing switch case until you run and test the program. | |
| /// That's why playing to your languages strengths is important. | |
| /// | |
| /// In the next version I'll take this futher and add conditions. | |
| fn scoreWord(word: string, modifiers: []const Modifier) Score { | |
| var score = Score{}; | |
| for (word) |letter| { | |
| score.total_score += scoreLetter(letter); | |
| } | |
| score.pre_modifiers_score = score.total_score; | |
| for (modifiers) |modifier| { | |
| switch (modifier.effect) { | |
| .none => {}, // do nothing | |
| .add_score => |bonus_points| { | |
| score.total_score += bonus_points; | |
| score.total_bonus_points += bonus_points; | |
| }, | |
| .multiply_score => |multiplier| { | |
| const total_score_as_float: f32 = @floatFromInt(score.total_score); | |
| score.total_score = @intFromFloat(total_score_as_float * multiplier); | |
| score.total_multiplier *= multiplier; | |
| }, | |
| } | |
| } | |
| return score; | |
| } | |
| fn scoreLetter(letter: u8) i32 { | |
| const score: i32 = switch (std.ascii.toUpper(letter)) { | |
| 'A', 'E', 'I', 'O', 'U', 'L', 'N', 'S', 'T', 'R' => 1, | |
| 'D', 'G' => 2, | |
| 'B', 'C', 'M', 'P' => 3, | |
| 'F', 'H', 'V', 'W', 'Y' => 4, | |
| 'K' => 5, | |
| 'J', 'X' => 8, | |
| 'Q', 'Z' => 10, | |
| else => 0, | |
| }; | |
| return score; | |
| } | |
| pub fn main() !void { | |
| // Create an array of Modifiers. | |
| const modifiers = [_]Modifier{ | |
| .{ .effect = .{ .multiply_score = 2.0 } }, | |
| .{ .effect = .{ .add_score = 3 } }, | |
| }; | |
| const input_words = [_]string{ "OKAY", "HELLO", "QUIET", "ARTWORK", "NONE", "OCTET" }; | |
| for (input_words) |word| { | |
| const score = scoreWord(word, &modifiers); | |
| debugPrint("score for '{s}': {d} total / {d} pre-modifiers / {d} bonus points / {d}x multiplier\n", .{ | |
| word, | |
| score.total_score, | |
| score.pre_modifiers_score, | |
| score.total_bonus_points, | |
| score.total_multiplier, | |
| }); | |
| } | |
| } | |
| const std = @import("std"); | |
| const string = []const u8; | |
| const debugPrint = std.debug.print; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //! This version adds conditions to the modifiers, such that | |
| //! the input word needs to satisfy various constraints for that | |
| //! modifier's effects to happen. | |
| const Modifier = struct { | |
| condition: union(enum) { | |
| none: void, // no constaints | |
| contains: string, // the word contains another word or letter | |
| starts_with: string, | |
| ends_with: string, | |
| starts_with_letter: string, // any letter within the substring is valid | |
| }, | |
| effect: union(enum) { | |
| none: void, | |
| add_score: i32, | |
| multiply_score: f32, | |
| }, | |
| }; | |
| const Score = struct { | |
| total_score: i32 = 0, | |
| pre_modifiers_score: i32 = 0, | |
| total_bonus_points: i32 = 0, | |
| total_multiplier: f32 = 1, | |
| }; | |
| fn scoreWord(word: string, modifiers: []const Modifier) Score { | |
| var score = Score{}; | |
| for (word) |letter| { | |
| score.total_score += scoreLetter(letter); | |
| } | |
| score.pre_modifiers_score = score.total_score; | |
| for (modifiers) |modifier| { | |
| // Now I've added a new switch block to check conditions. | |
| // The standard library in Zig comes with a few functions that makes this trivial. | |
| const passes_condition: bool = switch (modifier.condition) { | |
| .none => true, | |
| .contains => |substring| std.mem.containsAtLeast(u8, word, 1, substring), | |
| .starts_with => |substring| std.mem.startsWith(u8, word, substring), | |
| .ends_with => |substring| std.mem.endsWith(u8, word, substring), | |
| .starts_with_letter => |valid_letters| startsWithLetter(word, valid_letters), | |
| }; | |
| if (!passes_condition) continue; | |
| switch (modifier.effect) { | |
| .none => {}, // do nothing | |
| .add_score => |bonus_points| { | |
| score.total_score += bonus_points; | |
| score.total_bonus_points += bonus_points; | |
| }, | |
| .multiply_score => |multiplier| { | |
| const total_score_as_float: f32 = @floatFromInt(score.total_score); | |
| score.total_score = @intFromFloat(total_score_as_float * multiplier); | |
| score.total_multiplier *= multiplier; | |
| }, | |
| } | |
| } | |
| return score; | |
| } | |
| fn scoreLetter(letter: u8) i32 { | |
| const score: i32 = switch (std.ascii.toUpper(letter)) { | |
| 'A', 'E', 'I', 'O', 'U', 'L', 'N', 'S', 'T', 'R' => 1, | |
| 'D', 'G' => 2, | |
| 'B', 'C', 'M', 'P' => 3, | |
| 'F', 'H', 'V', 'W', 'Y' => 4, | |
| 'K' => 5, | |
| 'J', 'X' => 8, | |
| 'Q', 'Z' => 10, | |
| else => 0, | |
| }; | |
| return score; | |
| } | |
| fn startsWithLetter(haystack: string, valid_letters: string) bool { | |
| if (haystack.len == 0) return false; | |
| for (valid_letters) |letter| { | |
| if (std.ascii.toUpper(letter) == std.ascii.toUpper(haystack[0])) return true; | |
| } else return false; | |
| } | |
| pub fn main() !void { | |
| const modifiers = [_]Modifier{ | |
| .{ .condition = .{ .starts_with_letter = "AEIOU" }, .effect = .{ .multiply_score = 2 } }, | |
| .{ .condition = .{ .ends_with = "et" }, .effect = .{ .multiply_score = 1.5 } }, | |
| .{ .condition = .{ .contains = "one" }, .effect = .{ .add_score = 1 } }, | |
| }; | |
| const input_words = [_]string{ "OKAY", "HELLO", "QUIET", "ARTWORK", "NONE", "OCTET" }; | |
| for (input_words) |word| { | |
| const score = scoreWord(word, &modifiers); | |
| debugPrint("score for '{s}': {d} total / {d} pre-modifiers / {d} bonus points / {d}x multiplier\n", .{ | |
| word, | |
| score.total_score, | |
| score.pre_modifiers_score, | |
| score.total_bonus_points, | |
| score.total_multiplier, | |
| }); | |
| } | |
| } | |
| const std = @import("std"); | |
| const string = []const u8; | |
| const debugPrint = std.debug.print; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //! This version adds the concept of a Perk Card, which is closer to what the | |
| //! player will see, this is so a 'card' can have a list of modifiers in the background. | |
| // The array of structs for PerkCards is pure data, in a more fleshed out example I would | |
| // probably read this data in at runtime from a configeration file. | |
| const all_perk_cards = [_]PerkCard{ | |
| .{ | |
| .title = "EZ Peasy", | |
| .description = "If the word contains an E or a Z, add 1 bonus point, or 2 bonus points if it contains both.", | |
| .modifiers = &.{ | |
| .{ .condition = .{ .contains = "E" }, .effect = .{ .add_score = 1 } }, | |
| .{ .condition = .{ .contains = "Z" }, .effect = .{ .add_score = 1 } }, | |
| }, | |
| }, | |
| .{ | |
| .title = "Vowel Start", | |
| .description = "If the first tile is a Vowel, multiply the final score by 5x.", | |
| .modifiers = &.{ | |
| .{ .condition = .{ .starts_with_letter = "AEIOU" }, .deferred_effect = .{ .multiply_score = 5 } }, | |
| }, | |
| }, | |
| .{ | |
| .title = "ET Phone Home", | |
| .description = "If the words ends with 'ET', multiply the current score by 1.5x.", | |
| .modifiers = &.{ | |
| .{ .condition = .{ .ends_with = "ET" }, .effect = .{ .multiply_score = 1.5 } }, | |
| }, | |
| }, | |
| .{ | |
| .title = "Special K", | |
| .description = "K tiles are worth a bonus 1 point. If any tiles are K, multiplies the final score by 1.1x.", | |
| .modifiers = &.{ | |
| .{ | |
| .condition = .{ .contains = "K" }, | |
| .effect_per_substring = .{ | |
| .substring = "K", | |
| .effect = .{ .add_score = 1 }, | |
| }, | |
| }, | |
| .{ .condition = .{ .contains = "K" }, .deferred_effect = .{ .multiply_score = 1.1 } }, | |
| }, | |
| }, | |
| .{ | |
| .title = "Number Words", | |
| .description = "If the word contains a number from ONE to NINE, add that many points.", | |
| .modifiers = &.{ | |
| .{ .condition = .{ .contains = "ONE" }, .effect = .{ .add_score = 1 } }, | |
| .{ .condition = .{ .contains = "TWO" }, .effect = .{ .add_score = 2 } }, | |
| .{ .condition = .{ .contains = "THREE" }, .effect = .{ .add_score = 3 } }, | |
| .{ .condition = .{ .contains = "FOUR" }, .effect = .{ .add_score = 4 } }, | |
| .{ .condition = .{ .contains = "FIVE" }, .effect = .{ .add_score = 5 } }, | |
| .{ .condition = .{ .contains = "SIX" }, .effect = .{ .add_score = 6 } }, | |
| .{ .condition = .{ .contains = "SEVEN" }, .effect = .{ .add_score = 7 } }, | |
| .{ .condition = .{ .contains = "EIGHT" }, .effect = .{ .add_score = 8 } }, | |
| .{ .condition = .{ .contains = "NINE" }, .effect = .{ .add_score = 9 } }, | |
| }, | |
| }, | |
| }; | |
| const PerkCard = struct { | |
| title: string, | |
| description: string, | |
| modifiers: []const Modifier, | |
| }; | |
| const Modifier = struct { | |
| condition: Condition = .none, | |
| effect: Effect = .none, | |
| deferred_effect: Effect = .none, // useful for ensuring multiplies only effect the final score | |
| effect_per_substring: ?struct { | |
| substring: string, | |
| effect: Effect = .none, | |
| } = null, | |
| // Hoisted these types so they are no longer anonymous. | |
| const Condition = union(enum) { | |
| none: void, | |
| contains: string, | |
| starts_with: string, | |
| ends_with: string, | |
| starts_with_letter: string, | |
| }; | |
| const Effect = union(enum) { | |
| none: void, | |
| add_score: i32, | |
| multiply_score: f32, | |
| }; | |
| }; | |
| const Score = struct { | |
| total_score: i32 = 0, | |
| pre_modifiers_score: i32 = 0, | |
| total_bonus_points: i32 = 0, | |
| total_multiplier: f32 = 1, | |
| }; | |
| fn scoreWord(word: string, modifier_cards: []const PerkCard) Score { | |
| var score = Score{}; | |
| for (word) |letter| { | |
| score.total_score += scoreLetter(letter); | |
| } | |
| score.pre_modifiers_score = score.total_score; | |
| for (modifier_cards) |card| { | |
| var did_apply = false; | |
| for (card.modifiers) |modifier| { | |
| if (!doesWordSatisfyModifierCondition(word, modifier.condition)) continue; | |
| did_apply = true; | |
| // Hoisted the logic of this switch block to a function, you'll see just below | |
| // that logic is re-used for the new 'effect_per_substring' functionality. | |
| applyEffectToScore(modifier.effect, &score); | |
| if (modifier.effect_per_substring) |effect_per_substring| { | |
| const count = std.mem.count(u8, word, effect_per_substring.substring); | |
| for (0..count) |_| { | |
| applyEffectToScore(effect_per_substring.effect, &score); | |
| } | |
| } | |
| } | |
| if (did_apply) { | |
| debugPrint("Card '{s}' is being applied to word '{s}'\n", .{ card.title, word }); | |
| } | |
| } | |
| // We do this all again to the 'deferred_effect' can run. | |
| for (modifier_cards) |card| { | |
| for (card.modifiers) |modifier| { | |
| // I could have cached this somewhere from the first time we iterate the modifier cards, | |
| // but it's such a simple function its less overengineered to just check again here. | |
| if (!doesWordSatisfyModifierCondition(word, modifier.condition)) continue; | |
| applyEffectToScore(modifier.deferred_effect, &score); | |
| } | |
| } | |
| return score; | |
| } | |
| fn scoreLetter(letter: u8) i32 { | |
| const score: i32 = switch (std.ascii.toUpper(letter)) { | |
| 'A', 'E', 'I', 'O', 'U', 'L', 'N', 'S', 'T', 'R' => 1, | |
| 'D', 'G' => 2, | |
| 'B', 'C', 'M', 'P' => 3, | |
| 'F', 'H', 'V', 'W', 'Y' => 4, | |
| 'K' => 5, | |
| 'J', 'X' => 8, | |
| 'Q', 'Z' => 10, | |
| else => 0, | |
| }; | |
| return score; | |
| } | |
| fn doesWordSatisfyModifierCondition(word: string, condition: Modifier.Condition) bool { | |
| return switch (condition) { | |
| .none => true, | |
| .contains => |substring| std.mem.containsAtLeast(u8, word, 1, substring), | |
| .starts_with => |substring| std.mem.startsWith(u8, word, substring), | |
| .ends_with => |substring| std.mem.endsWith(u8, word, substring), | |
| .starts_with_letter => |valid_letters| startsWithLetter(word, valid_letters), | |
| }; | |
| } | |
| fn applyEffectToScore(effect: Modifier.Effect, score: *Score) void { | |
| switch (effect) { | |
| .none => {}, // do nothing | |
| .add_score => |bonus_points| { | |
| score.total_score += bonus_points; | |
| score.total_bonus_points += bonus_points; | |
| }, | |
| .multiply_score => |multiplier| { | |
| const total_score_as_float: f32 = @floatFromInt(score.total_score); | |
| score.total_score = @intFromFloat(total_score_as_float * multiplier); | |
| score.total_multiplier *= multiplier; | |
| }, | |
| } | |
| } | |
| fn startsWithLetter(haystack: string, valid_letters: string) bool { | |
| if (haystack.len == 0) return false; | |
| for (valid_letters) |letter| { | |
| if (std.ascii.toUpper(letter) == std.ascii.toUpper(haystack[0])) return true; | |
| } else return false; | |
| } | |
| pub fn main() !void { | |
| const input_words = [_]string{ "OKAY", "HELLO", "QUIET", "ARTWORK", "NONE", "OCTET", "ZEUS" }; | |
| for (input_words) |word| { | |
| const score = scoreWord(word, &all_perk_cards); | |
| debugPrint("score for '{s}': {d} total / {d} pre-modifiers / {d} bonus points / {d}x multiplier\n", .{ | |
| word, | |
| score.total_score, | |
| score.pre_modifiers_score, | |
| score.total_bonus_points, | |
| score.total_multiplier, | |
| }); | |
| } | |
| } | |
| const std = @import("std"); | |
| const string = []const u8; | |
| const debugPrint = std.debug.print; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment