Skip to content

Instantly share code, notes, and snippets.

@perky
Created June 29, 2026 12:03
Show Gist options
  • Select an option

  • Save perky/398ddd4a187eeb633842bba6799bb32e to your computer and use it in GitHub Desktop.

Select an option

Save perky/398ddd4a187eeb633842bba6799bb32e to your computer and use it in GitHub Desktop.
Perky's Perks
//! 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 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 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 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