I was looking through my old projects and found a couple of Rock-Paper-Scissors games I wrote in C a few years back. Why more than one? Because programming is fun, but also because I was experimenting. We're going to look at both versions and talk about how math actually shows up in code. This will not be some generic "you should know math" lecture, but a before/after comparison grounded in my humble experience.
Why this matters if you're self-taught
If you're a fresh CS student or a self-taught developer, there's a decent chance you don't fully buy that math concepts matter day to day. Fair. Here's how I think about it: programming gives you a lot of room to be creative in how you solve problems, but your creativity is powered by what you actually know. Knowledge is the raw material here and math is one of the biggest sources of that raw material.
Here's an example: if you wanted to write a loop in Rust with unusual control flow, and you didn't know that Rust's while is really just shorthand for the common pattern using loop + if + else + break, you'd spend a long time fighting while to do something it wasn't built for. If you knew that, you'd just build the custom loop you actually need.
Or the other direction: a senior dev once took a buggy 12-line React component I wrote and refactored it into 3 correct lines, because he knew which hooks and methods to reach for. Your imagination still needs building blocks to arrange. Math, at a certain stage, is where a lot of those blocks come from. It gives you a precise, calculable, universal way to reason about and express a problem instead of just poking at it until it works.
Let's look at actual code. The full source code for both implementations is public on GitHub here
Iteration 1: the naive version
Here's the main function of the first version I wrote:
int main(void) {
srand(time(NULL));
int random_int = (rand() % ((length_options + 1) - 1)) + 1;
int option;
printf("Let's play Rock Paper Scissors!\n");
printf("First to 3 points wins!\n");
while ((option != 1 && option != 2 && option != 3) ||
(player_score != 3 && my_score != 3)) {
round_number += 1;
printf("\n===============\n");
printf("Round %d\n", round_number);
printScores();
printOptions();
scanf("%d", &option);
if (option == 0 || option > length_options) {
printf("\nPlease select a number ranging from 1 to 3 according to your "
"desired option.\n\n");
} else {
printf("\nYou picked %s\n", options[option - 1].name);
printf("I picked %s\n", options[random_int - 1].name);
declare_round_winner((option - 1), (random_int - 1));
}
printf("\n===============\n");
}
printScores();
declare_overall_winner();
return 0;
}
Quick heads up before I explain anything: there's a bug in this loop condition. option is declared but never given a value before the while check reads it:
while ((option != 1 && option != 2 && option != 3) || ...)
Reading an uninitialized variable in C is undefined behavior. It happens to "work" here because whatever garbage value is sitting in that memory slot almost never equals 1, 2, or 3, so the loop starts anyway. But that's luck, not correctness. If you're newer to C, this is a good example of a bug that won't show up as a crash, it'll just quietly rely on the right garbage value. Moving on.
The supporting pieces are what you'd expect: an Option struct holding a name and value, an array of three options (Rock, Paper, Scissors), score counters, a printScores function, a printOptions function that loops over the array so adding a fourth option later doesn't require touching the print logic, and input validation that re-prompts on an out-of-range choice.
The main focal point for this post is how the winner gets decided:
int check_rules(int player_option, int my_option) {
switch (player_option) {
case 0:
if (my_option == 0) return 0;
else if (my_option == 1) return 1;
else return -1;
case 1:
if (my_option == 0) return -1;
else if (my_option == 1) return 0;
else return 1;
case 2:
if (my_option == 0) return 1;
else if (my_option == 1) return -1;
else return 0;
default:
return 0;
}
}
This checks every possible value of player_option against every possible value of my_option and returns -1 (loss), 0 (draw), or 1 (win). It's ugly, but it's also the most direct solution I could think of at the time. It also doesn't scale. Let's say you wanted to play the more advanced and respected version of the game: Rock-Paper-Scissors-Lizard-Spock, you'd have to add Lizard and Spock. You'd also have to rewrite every branch. Sheldon wouldn't approve.
Iteration 2: what changed, and why
Instead of guessing which part needed optimizing, I looked at the code and realized the winner-check was the ugliest and least scalable part of it so I wrote out every possible outcome as a table:
| Rock | Paper | Scissors | |
|---|---|---|---|
| Rock | Draw | Rock Loss | Rock Win |
| Paper | Paper Win | Draw | Paper Loss |
| Scissors | Scissors Loss | Scissors Win | Draw |
Then I substituted numbers for the outcomes (Win = 1, Draw = 0, Loss = -1):
| Rock | Paper | Scissors | |
|---|---|---|---|
| Rock | 0 | -1 | 1 |
| Paper | 1 | 0 | -1 |
| Scissors | -1 | 1 | 0 |
That's a lookup table. I originally called this "a matrix," but I want to be more precise here than I was when I first wrote this, because the interesting math isn't that it has a nice grid-looking shape. It's that the values in this grid aren't arbitrary. Notice the pattern. It's skew-symmetric (flip any cell across the diagonal and the sign flips), and it repeats in a cycle. This is called modular arithmetic. If you assign Rock = 0, Paper = 1, Scissors = 2, the outcome of any matchup is:
outcome = (player - opponent + 3) % 3
Where a result of 1 means the player wins, 2 means the player loses, and 0 is a draw (you'd remap those three values to +1/0/-1 to match the table above). That's the math concept doing the work here: a cyclic relationship expressed with the modulo operator, the same operation that underlies clock arithmetic, hashing, and a good chunk of the number theory that shows up in cryptography (RSA leans on modular exponentiation directly). A 3x3 lookup table is one way to encode that relationship.
In code, the lookup table version looks like this:
int rules_matrix[3][3] = {{0, -1, 1}, {1, 0, -1}, {-1, 1, 0}};
int check_rules_matrix(int x, int y) {
return rules_matrix[x][y];
}
void update_scores(int outcome) {
switch (outcome) {
case 1:
computer_score += 1;
printf("\nI won this round!\n");
break;
case -1:
player_score += 1;
printf("\nYou won this round!\n");
break;
default:
printf("\nDraw\n");
break;
}
}
void declare_round_winner(int c_option, int p_option) {
update_scores(check_rules_matrix(c_option, p_option));
}
Determining a winner is now a single array access instead of nine branches. Add Lizard and Spock, and you're extending a 3x3 table to a 5x5 table and updating the options array. No switch statements to rewrite.
Does it actually matter?
I wanted to check the performance claim instead of just asserting it, so I benchmarked both approaches directly, 100 million calls each, isolated from I/O:
#define ITERATIONS 100000000
int check_rules_switch(int player_option, int my_option) {
switch (player_option) {
case 0:
if (my_option == 0) return 0;
else if (my_option == 1) return 1;
else return -1;
case 1:
if (my_option == 0) return -1;
else if (my_option == 1) return 0;
else return 1;
case 2:
if (my_option == 0) return 1;
else if (my_option == 1) return -1;
else return 0;
default:
return 0;
}
}
static const int rules_matrix[3][3] = {
{ 0, -1, 1},
{ 1, 0, -1},
{-1, 1, 0}
};
int check_rules_matrix(int x, int y) {
return rules_matrix[x][y];
}
Results, run three times:
1. Switch/Branching Method: 0.343620 seconds (3.44 ns per call)
2. Matrix Lookup Method: 0.275987 seconds (2.76 ns per call)
3. Switch/Branching Method: 0.342246 seconds (3.42 ns per call)
4. Matrix Lookup Method: 0.272867 seconds (2.73 ns per call)
5. Switch/Branching Method: 0.340185 seconds (3.40 ns per call)
6. Matrix Lookup Method: 0.272581 seconds (2.73 ns per call)
The lookup table wins consistently, by about 0.7 nanoseconds per call, roughly a 20% improvement in this specific isolated benchmark. But let's be clear on what that means. 0.7 nanoseconds is nothing in absolute terms. In a real Rock-Paper-Scissors game bottlenecked by scanf waiting on a human, this difference is completely invisible.
So yeah this isn't exactly going to save your production system (unless that's a specific problem your system has). The main point is that recognizing the modular structure of the problem gave me a solution that was simultaneously simpler to read, easier to extend, and measurably faster, all from the same insight. And that's how math helps. Besides making code faster, it also changes what kind of solution occurs to you in the first place.
This is the fact I keep noticing as I move further into security work. A lot of what looks like "cleverness" in an optimized or exploited system is really just someone noticing the underlying structure before anyone else bothered to look for it.
Addendum: what if you skip the table entirely?
If the outcome is really just modular arithmetic, why store a table at all? You can compute the result directly:
int check_rules_mod(int x, int y) {
return ((x - y + 4) % 3) - 1;
}
Same return convention as check_rules_matrix (1 if x wins, -1 if y wins, 0 for a draw). I checked this against all 9 possible matchups by brute force and it matches exactly, so correctness isn't in question.
Performance is where this got more interesting than I expected, and where I want to be upfront about the limits of what I actually understand.
While putting this post together, I recompiled the exact same switch-vs-matrix benchmark file from above with the -O2 optimization flag, mostly out of curiosity, and the ranking completely inverted:
1. Switch/Branching Method: 0.119248 seconds (1.19 ns per call)
2. Matrix Lookup Method: 0.133597 seconds (1.34 ns per call)
1. Switch/Branching Method: 0.120751 seconds (1.21 ns per call)
2. Matrix Lookup Method: 0.128811 seconds (1.29 ns per call)
1. Switch/Branching Method: 0.122600 seconds (1.23 ns per call)
2. Matrix Lookup Method: 0.132499 seconds (1.32 ns per call)
The only thing I changed was adding the build flag, and switch went from consistently losing to consistently winning. I don't yet know enough about what the compiler is actually doing under -O2 to explain that properly, branching, inlining, and instruction-level optimization are a whole subject I haven't put real time into, and I'd rather say that plainly than dress up a guess as an explanation.
I also wanted numbers for the modular version above, not just correctness. Getting a fair three-way comparison meant controlling for whether the compiler inlines each function, which isn't something I know how to force myself yet, so I had AI help write a benchmark harness that pins that down explicitly. I compiled and ran it on my own machine, these are the numbers I got:
All three functions forced to inline (best case):
1. Switch/Branching Method: 1.293 ns per call
2. Matrix Lookup Method: 1.339 ns per call
3. Modular Arithmetic: 1.261 ns per call
All three functions forced to be real function calls:
1. Switch/Branching Method: 2.261 ns per call
2. Matrix Lookup Method: 1.697 ns per call
3. Modular Arithmetic: 1.793 ns per call
Same story as the -O2 flip: which one wins depends on a compiler decision, not on the code itself. Inlined, switch is fastest and mod edges out matrix. Forced into real function calls, matrix takes over and switch falls behind badly. Mod never loses badly either way, but I'm not going to dress that up as "modular arithmetic is the safe choice performance-wise" when I can't yet explain why the ranking moves the way it does.
Consider this a flag rather than a finding: something real is going on here, across two separate build decisions, it's outside what I currently know, and it's on the list for a proper, dedicated post once I've actually built the compiler knowledge to back it up instead of just reporting numbers I can't fully explain.
Cover photo by Antony Hyson Seltran on Unsplash
Top comments (0)