g[ilber]t.de

A C# implementation of a very, very old game...

Santa Paravia en Fiumaccio in C#

Update: You can now experience the game on this page thanks to a WebAssembly conversion I did.

Entering the rabbit hole

Recently, I got into a rabbit hole of old city planning games. The very first I found was The Sumerian Game. Created by Mabel Addis in 1964, it was one of the earliest text-based strategy video games of land and resource management.

Other games in the same vain are Hamurabi (1968), Dukedom (1976) and Dynasty (1978).

Nowadays, text-based games see some kind of renaissance, thanks to Warsim or Roots of Harmony

In this article, I will focus on Santa Paravia en Fiumaccio, a game set in the 15th century in italy. This game was created in 1978 by George Blank, and published in the December 1978 issue of SoftSide magazine for the TRS-80.

Wikipedia says this about the game:

Based loosely on the text game Hamurabi, Santa Paravia and Fiumaccio was an early god game. It combined 'guns and butter' economic tradeoffs with graphic development of a kingdom with buildings being constructed and shown on the screen as well as character development, shown as progressive promotions from baron to king.
— Wikipedia

The goal of the game is to progress from Lady (or Sir) to H.R.H Queen (or H.R.H King), meanwhile defending attackers and distributing grain according to need and availablilty. You can (and should) buy additional land, grow grain and control taxes.

A fellow called Thomas Knox converted the original TRS-80 code to ANSI-C in 2003. In his conversion, he skipped the map part of the original game. We will get to that when we take a look at the implementation.

Let's dive into the code

Note: If you are interested in the original TRS-80 listing, you will find it here: SoftSide Magazine Issue 03 (Santa Parvia)

The converted code can be found at GitHub.

Disclaimer: I'm no mathematician. I'm a programmer. As such, if I try to describe algorithms or equations used in this game, I'll do that using the terminology of a programmer. There is no point in using a language I do not speak.

Let's unpack the main Run() method of the C# conversion.

public void Run() { Randomizer = new Random(); Console.WriteLine("Santa Paravia and Fiumaccio"); Console.WriteLine(); Console.WriteLine("Do you wish instructions (Y or N)?"); string? response = Console.ReadLine(); if (!String.IsNullOrWhiteSpace(response) && (response.ToLower()[0] == 'y')) { PrintInstructions(); } Console.WriteLine("How many people want to play (1 to 6)?"); response = Console.ReadLine(); NumberOfPlayers = Convert.ToInt32(response); if (NumberOfPlayers < 1 || NumberOfPlayers > 6) { Console.WriteLine("Thanks for playing!"); return; } Console.WriteLine("What will be the difficulty of this game:"); Console.WriteLine("1. Apprentice"); Console.WriteLine("2. Journeyman"); Console.WriteLine("3. Master"); Console.WriteLine("4. Grand Master"); Console.WriteLine(); Console.Write("Choose: "); gameLevel = Convert.ToInt32(Console.ReadLine()); if (gameLevel < 1) { gameLevel = 1; } if (gameLevel > 4) { gameLevel = 4; } for(int i = 0; i < NumberOfPlayers; i++) { Console.WriteLine("Who is the ruler of {0}?", CityList[i]); string? rulersName = Console.ReadLine(); if (String.IsNullOrWhiteSpace(rulersName)) { i--; continue; } Console.WriteLine("Is {0} male or female (m or f)?", rulersName); string? gender = Console.ReadLine()?.ToLower() ?? "f"; bool isMale = (gender[0] == 'm'); Players.Add(new Player()); InitializePlayer(Players[i], 1400, i, gameLevel, rulersName, isMale); } PlayGame(Players, NumberOfPlayers); }

The game relies in large parts on randomization. As such, it is no surprise that we need to initialize random functionality. From line 3 ‐ 12, the game will setup the randomizer, greet the player and ask, if the player needs further instructions. If confirmed, the game calls PrintInstructions().

A computer back in the days was a highlight to everyone. They were rather expensive, so there was usually one computer for the entire family. As such, it is no wonder that you are able to play this game at the same terminal with more than 1 player. Lines 14 ‐ 21 handle the logic to request the amount of players that are currently in this session.

Lines 23 ‐ 38 set up the difficulty level for this game. It offers four level of difficulty. The numeric value of difficulty is used in the progression calculation of the game, which we will see more of later on.

Depending on the amount of players, lines 40 ‐ 56 loop several times and setup the list of players by asking for names and initializing the player. InitializePlayer() is used to set several variables to its' initial state. That are not all variables that belong to the player record, but it's already an impressive list:

public void InitializePlayer(Player player, int year, int city, int level, string name, bool maleOrFemale) { player.Cathedral = 0; player.City = CityList[city]; player.Clergy = 5; player.CustomsDuty = 25; player.Difficulty = level; player.GrainPrice = 25; player.GrainReserve = 5000; player.IncomeTax = 5; player.IsBankrupt = false; player.IsDead = false; player.InvadeMe = false; player.IWon = false; player.Justice = 2; player.Land = 10000; player.LandPrice = 10.0; player.MaleOrFemale = maleOrFemale; player.Marketplaces = 0; player.Merchants = 25; player.Mills = 0; player.Name = name; player.Nobles = 4; player.OldTitle = 1; player.Palace = 0; player.PublicWorks = 1.0; player.SalesTax = 10; player.Serfs = 2000; player.Soldiers = 25; player.TitleNum = 1; if (player.MaleOrFemale == true) player.Title = MaleTitles[0]; else player.Title = FemaleTitles[0]; if (city == 6) player.Title = "Baron"; player.Treasury = 1000; player.WhichPlayer = city; player.Year = year; player.YearOfDeath = year + 20 + Random(35); return; }

Random(35); is another function that is used rather often in this game. It will provide a random integer between 0 and the supplied parameters, exclusively. In this case, we will receive an integer between 0 and 34.

The game also predetermines your year of death by using the start year (that is always 1400), adding 20 to it (so you will at least play 20 rounds), and then randomly give you the aforementioned 0 to 34 more extra rounds. Of course, you might be defeated by other events, but at some point, you will lose because you died.

Back to the original code. The only thing that is left to do is to call PlayGame():

private void PlayGame(List<Player> players, int numberOfPlayers) { bool AllDead, Winner; int WinningPlayer = 0; Player Baron; AllDead = false; Winner = false; Baron = new Player(); InitializePlayer(Baron, 1400, 6, 4, "Peppone", true); while (AllDead is false && Winner is false) { for(int i = 0; i < NumberOfPlayers; i++) { if (!players[i].IsDead) { NewTurn(players[i], NumberOfPlayers, players, Baron); } } AllDead = true; for(int i = 0; i < NumberOfPlayers; i++) { if (AllDead && players[i].IsDead is false) { AllDead = false; } } for(int i = 0; i < NumberOfPlayers; i++) { if (players[i].IWon is true) { Winner = true; WinningPlayer = i; } } } if (AllDead is true) { Console.WriteLine("The game has ended."); return; } Console.WriteLine("Game Over. {0} {1} wins.", players[WinningPlayer].Title, players[WinningPlayer].Name); }

In this method, the computer opponent is introduced, lines 8 and 9 initialize a Baron called "Peppone".

Lines 11 ‐ 39 are running the main loop as long as at least one player is not dead, or none of the players has won the game. That loop triggers the NewTurn() method for each player in the game. The Baron never participates in these turns. It's only job in this game is to be the attacker if only one player is playing the game.

private void NewTurn(Player player, int numberOfPlayers, List<Player> players, Player baron) { GenerateHarvest(player); NewLandAndGrainPrices(player); BuySellGrain(player); ReleaseGrain(player); if (player.InvadeMe is true) { int i = 0; for(i = 0; i < NumberOfPlayers; i++) { if (i != player.WhichPlayer) { if (players[i].Soldiers > (player.Soldiers * 2.4)) { _ = AttackNeighbor(players[i], player); i = 9; } } } if (i != 9) { _ = AttackNeighbor(baron, player); } } AdjustTax(player); DrawMap(player); StatePurchases(player, numberOfPlayers, players); _ = CheckNewTitle(player); player.Year++; if (player.Year == player.YearOfDeath) { ImDead(player); } if (player.TitleNum >= 7) { player.IWon = true; } }

Finally, we are able to dive into the nooks and crannies of this game. Each turn, the game will run the following steps:

The four phases of the game

The original explanation for the game calls out four phases: Harvest Phase, Tax Phase, Map Phase and Public Works Phase. We will dive into each of the phases, referencing the code we just uncovered, and talk a bit about how the game does what (or what I think the game does - I might not have fully understood everything).

Harvest Phase

The functions involved in this phase are the following:

GenerateHarvest()

The game calculates a random value as harvest, by adding two values. Ones goes from 0 ‐ 4 and the other from 0 ‐ 6 - the result is then divided by 2 in an integer division. Rats are also assigned, randomly, on a scale from 0 ‐ 49.

Finally, the game will calculate the grain reserve by multiplying the existing grain reserve with 100, subtracting the grain reserve multiplied by the amount of rats, and divide everything by 100 again: ((player.GrainReserve * 100) - (player.GrainReserve * player.Rats)) / 100

This means that, if Rats goes up to 50, your grain reserve will be cut in half, whereas if there are zero rats, your reserve will remain unchanged.

This graph shows the distribution for grain reserve, given that the amount of rats is 50 (the green, dashed graph) or 0 (the purple, solid graph), and X gives the amount of grain in reserve.
NewLandAndGrainPrices()

This method calculates the grain reserve, the grain demand, land price and what the rats ate. It does that by using two variables, x and y. For this one, I will include the code, so you can follow along with my explanation.

private void NewLandAndGrainPrices(Player player) { double x, y, myRandom; int h; //Generate an offset for use in later int->float conversions. myRandom = Randomizer.NextDouble(); /* If you think this C# code is ugly, you should see the original C. */ x = player.Land; y = ((player.Serfs - player.Mills) * 100.0) * 5.0; if (y < 0.0) { y = 0.0; } if (y < x) { x = y; } y = player.GrainReserve * 2.0; if (y < x) { x = y; } y = player.Harvest + (myRandom + 0.5); h = Convert.ToInt32(x * y); player.GrainReserve += h; player.GrainDemand = (player.Nobles * 100) + (player.Cathedral * 40) + (player.Merchants * 30); player.GrainDemand += ((player.Soldiers * 10) + (player.Serfs * 5)); player.LandPrice = (3.0 * player.Harvest + Convert.ToDouble(Random(6)) + 10.0) / 10.0; if (h < 0) { h *= -1; } if (h < 1) { y = 2.0; } else { y = Convert.ToDouble(player.GrainDemand / (double)h); if (y > 2.0) { y = 2.0; } } if (y < 0.8) { y = 0.8; } player.LandPrice *= y; if(player.LandPrice < 1.0) { player.LandPrice = 1.0; } player.GrainPrice = Convert.ToInt32(((6.0 - player.Harvest) * 3.0 + Random(5) + Random(5)) * 4.0 * y); player.RatsAte = h; }

x gets the value of player.Land, which is the amount of land the player owns.

y is calculated in this way: y = ((player.Serfs - player.Mills) * 100.0) * 5.0;

Afterwards, the method performs a sanity check on the y value: If it becomes less than 0.0, it will be set to 0.0, so y should never be negative. If y is smaller than x, than x will be set to y.

y than receives a new value. It is calculated by player.GrainReserve * 2.0. Again, another sanity check is performed, with the same result: If y is smaller than x, y will be assigned to x.

That means that, by now, x is either:

After all that, y will be reused:

y = player.Harvest + (myRandom + 0.5); MyRandom is an offset, calculated in line 7. Basically, it is a random double between 0.0 and 1.0

h = Convert.ToInt32(x * y); is the final variable needed. So the player's harvest (plus offset) is multiplied by either of the three possibilities mentioned earlier, and then converted to int. As discussed before, harvest will be a value between 0 and 5.

The result of the previous operation is than added to GrainReserve. This gives us the very first value for this operation, the new grain reserve.

Next, the grain demand is calculated. That operation is pretty straight forward.

player.GrainDemand = (player.Nobles * 100) + (player.Cathedral * 40) + (player.Merchants * 30); player.GrainDemand += ((player.Soldiers * 10) + (player.Serfs * 5));

The land price calculation spans from 35 to 65. Let's dive a bit deeper into the mechanics:

The initial land price is calculated by multiplying the players harvest (0 - 5) with 3, adding a random number between 0 - 5, and adding 10 to it. This result is then divided by ten. With this formula, the result of the land price calculation varies between 1 and 3.

However, from playing the game, I know that there are land prices below 2, so there is, of course, more happening. The next block will do some operations on h and y. Basically, h will be flipped if it is negative (so it get's positive), and if h is smaller than 1, then y is set to 2. If h is bigger than 2, the grain demand is divided by h. y on the other hand is capped by 2, and by 0.8 on the lower end.

The land price is than further reduced because it get's multiplied by y. If land price goes below 1.0 though, it will be reset to 1.0

Grain price is finally calculated by this formula:

Convert.ToInt32(((6.0 - player.Harvest) * 3.0 + Random(5) + Random(5)) * 4.0 * y); - again, lot's of random values that get added. From my understanding, the lowest value this formula can output is 9.6, and the highest is 208. That resonates with my experience during the game. While the randomized inputs carry quite some variance in the result, the biggest impact comes from the harvest. And that totally makes sense. The higher the harvest number goes, the lower the price for grain goes (as there is no shortage).

Finally, the value that calculated for h, will also be the fate of your reserve in terms of how much the rats ate.

BuySellGrain()

Not much to see here. This one will allow you to sell or buy either land or grain. It will then go ahead and perform the necessary operations on the resources, and will also add or subtract to or from the treasury.

ReleaseGrain

This one is fun. It will be the most complex function we will take a look at today.

private int ReleaseGrain(Player player) { double xp, zp; double x, z; string result; int howMuch, maximum, minimum; bool isOK; isOK = false; howMuch = 0; minimum = player.GrainReserve / 5; maximum = player.GrainReserve - minimum; while(isOK is false) { Console.WriteLine("How much grain will you release for consumption?"); Console.WriteLine("1 = Minimum ({0})", minimum); Console.WriteLine("2 = Maximum ({0})", maximum); Console.Write("or enter a number: "); result = Console.ReadLine() ?? "0"; howMuch = Convert.ToInt32(result); if (howMuch == 1) howMuch = minimum; if (howMuch == 2) howMuch = maximum; //Are we being Scrooge? if (howMuch < minimum) Console.WriteLine("You must at least release 20% of your reserves."); else if (howMuch > maximum) //Whoa! Slow down there son! Console.WriteLine("You must keep at least 20%!"); else isOK = true; } player.SoldierPay = 0; player.MarketRevenue = 0; player.NewSerfs = 0; player.DeadSerfs = 0; player.TransplantedSerfs = 0; player.FleeingSerfs = 0; player.InvadeMe = false; player.GrainReserve -= howMuch; z = Convert.ToDouble(howMuch) / Convert.ToDouble(player.GrainDemand) - 1.0; if (z > 0.0) { z /= 2.0; } if (z > 0.25) { z = z / 10.0 + 0.25; } zp = 50.0 - Convert.ToDouble(player.CustomsDuty) - Convert.ToDouble(player.SalesTax) - Convert.ToDouble(player.IncomeTax); if (zp < 0.0) { zp *= Convert.ToDouble(player.Justice); } zp /= 10.0; if (zp > 0.0) { zp += (3.0 - Convert.ToDouble(player.Justice)); } z += Convert.ToDouble(zp) / 10.0; if (z > 0.5) z = 0.5; if (howMuch < (player.GrainDemand - 1)) { x = (Convert.ToDouble(player.GrainDemand) - Convert.ToDouble(howMuch)) / player.GrainDemand * 100.0 - 9.0; xp = Convert.ToDouble(x); if (x > 65.0) x = 65.0; if (x < 0.0) { xp = 0.0; x = 0.0; } SerfsProcreating(player, 3.0); SerfsDecomposing(player, xp + 8.0); } else { SerfsProcreating(player, 7.0); SerfsDecomposing(player, 3.0); if ((player.CustomsDuty + player.SalesTax) < 35) player.Merchants += Random(4); if (player.IncomeTax < Random(28)) { player.Nobles += Random(2); player.Clergy += Random(3); } if (howMuch > Convert.ToInt32(Convert.ToDouble(player.GrainDemand) * 1.3)) { zp = Convert.ToDouble(player.Serfs) / 1000.0; z = Convert.ToDouble(howMuch - player.GrainDemand) / Convert.ToDouble(player.GrainDemand) * 10.0; z *= (zp * Convert.ToDouble(Random(25))); z += Convert.ToDouble(Random(40)); player.TransplantedSerfs = Convert.ToInt32(z); player.Serfs += player.TransplantedSerfs; Console.WriteLine("{0} serfs move to the city.", player.TransplantedSerfs); zp = Convert.ToDouble(z); z = (Convert.ToDouble(zp) * Convert.ToDouble(Randomizer.Next(0, Int16.MaxValue)) / Convert.ToDouble(Int16.MaxValue)); //((float)zp * (float)rand()) / (float)RAND_MAX; if (z > 50.0) z = 50.0; player.Merchants += Convert.ToInt32(z); player.Nobles++; player.Clergy += 2; } } if (player.Justice > 2) { player.JusticeRevenue = player.Serfs / 100 * (player.Justice - 2) * (player.Justice - 2); player.JusticeRevenue = Random(player.JusticeRevenue); player.Serfs -= player.JusticeRevenue; player.FleeingSerfs = player.JusticeRevenue; Console.WriteLine("{0} serfs flee harsh justice.", player.FleeingSerfs); } player.MarketRevenue = player.Marketplaces * 75; if (player.MarketRevenue > 0) { player.Treasury += player.MarketRevenue; Console.WriteLine("Your market earned {0} florines.", player.MarketRevenue); } player.MillRevenue = player.Mills * (55 + Random(250)); if (player.MillRevenue > 0) { player.Treasury += player.MillRevenue; Console.WriteLine("Your woolen mill earned {0} florins.", player.MillRevenue); } player.SoldierPay = player.Soldiers * 3; player.Treasury -= player.SoldierPay; Console.WriteLine("You paid your soldiers {0} florins.", player.SoldierPay); Console.WriteLine("You have {0} serfs in your city.", player.Serfs); Console.WriteLine("(Press Enter)"); _ = Console.ReadLine(); if ((player.Land / 1000) > player.Soldiers) { player.InvadeMe = true; return 3; } if ((player.Land / 500) > player.Soldiers) { player.InvadeMe = true; return 3; } return 0; }

Lines 13 ‐ 38 deal with the fundamental question of how much grain you will distribute for consumption. The game limits you in what you are able to do: You should release at least 20% of your available grain, but you must not release more than 80%. Once these constraints are satisfied, you are allowed to continue.

Lines 40 ‐ 46 reset all necessary variables for this routine on our player class to the initial start values. Line 47 substracts your choosen amount of grain from your reserve.

Our well-known z variable is back again. This time, it represents the ratio between your choosen grain amount and the demand. It then shifts this value by one. This means, that if you satisfied the demand for grain, y will be 0. If you undercut the demand, the value will be negative. If you "overspend", the value will be positive.

Interesting enough: If you overspend, the algorithm will cut y in half (in line 53). If z is then still bigger than 0.25, the game will take this value, divide it by 10 and add 0.25 to it. I'm not quite sure why, but maybe we will figure it out down the line.

In line 61, zp is introduced: It will be added to z in line 75, after being divided by 10. zp starts with an initial value of 50, which is reduced by the customs duty, the sales tax and the player income tax. The taxes have limits:

This means, that zp can get as low as -125. If zp goes down to a negative value, it will be multiplied by justice. As a reminder, that's what justice stands for:

Depending on your justice, zp can go down to as much as -500. However, in line 68, the value is again divided by 10, effectively capping it at -50 to 5. If zp is still bigger than 0, then in line 72, justice is subtracted from 3 and added to the value of zp. Finally, in line 75, zp will again be divided by 10 and added to our previous value of z.

Interesting enough, now z also gets a cap in line 77, which will limit it to 0.5.

Between the lines 80 and 129, the code branches off depending on whether you decided to spend less or more than the demand of grain.

If you happen to spend less than the demand, lines 82 ‐ 96 will calculate the effects of this:

(Convert.ToDouble(player.GrainDemand) - Convert.ToDouble(howMuch)) / player.GrainDemand * 100.0 - 9.0

This code will give you a value of x, which is essentially the percentage of how much your released grain deviates from the demanded grain. If the demand is higher then what you provided, x will be positive. If the demand is lower than what you provided, x will be negative. However, x will be capped to 65. So if the demand is 20000, but you can only supply 2000, x won't go up to 81, it will be capped at 65. If you overspend, this has no effect in this part of the code, as it will simply set x and xp to zero. That is kind of interesting, because this branch only should get entered if the provided amount of grain is already lower than the demand. So x should never get too low.

The next two calls are SerfsProcreating(player, 3.0) and SerfsDecomposing(player, xp + 8.0).

These two function essentially use the same algorithmic logic, of course changing different values in the player class (NewSerfs for SerfsProcreating, and DeadSerfs for SerfsDecomposing). Btw, I did not come up with the name for theses methods.

private void SerfsProcreating(Player player, double myScale) { int absc = 0; double ord = 0.0; absc = Convert.ToInt32(myScale); ord = myScale - Convert.ToDouble(absc); player.NewSerfs = Convert.ToInt32((Random(absc) + ord) * Convert.ToDouble(player.Serfs) / 100.0); player.Serfs += player.NewSerfs; Console.WriteLine("{0} serfs born this year.", player.Serfs); }

If you spend more grain than demand was set, the game will start with the calculation of how many serfs were born and how many died. If your customs duty and sales tax combined is less than 35, the game will randomly increase the amount of merchants by 0 to 3. You will also receive nobles and clergies randomly, if your income tax is below a random limit between 0 and 27.

If your surplus of grain released is more than 30%, serfs will start to move into your city. The code will also randomly apply a (reasonably) large amount of merchants, increase the nobles by one and add two clergies into your city. So releasing 130% of the grain in demand kind of pays off:

if (howMuch > Convert.ToInt32(Convert.ToDouble(player.GrainDemand) * 1.3)) { zp = Convert.ToDouble(player.Serfs) / 1000.0; z = Convert.ToDouble(howMuch - player.GrainDemand) / Convert.ToDouble(player.GrainDemand) * 10.0; z *= (zp * Convert.ToDouble(Random(25))); z += Convert.ToDouble(Random(40)); player.TransplantedSerfs = Convert.ToInt32(z); player.Serfs += player.TransplantedSerfs; Console.WriteLine("{0} serfs move to the city.", player.TransplantedSerfs); zp = Convert.ToDouble(z); z = (Convert.ToDouble(zp) * Convert.ToDouble(Randomizer.Next(0, Int16.MaxValue)) / Convert.ToDouble(Int16.MaxValue)); //((float)zp * (float)rand()) / (float)RAND_MAX; if (z > 50.0) z = 50.0; player.Merchants += Convert.ToInt32(z); player.Nobles++; player.Clergy += 2; }

If your Justice is higher than 2, which equals harsh or outrageous, than there is a punishment called JustiveRevenue. It is calculated by dviding the amount of serfs by 100, multiplied by 16 (only happens if your justice is "outrageous"). The result of this calculation will be the upper limit of a random value, which will be the final JusticeRevenue. This will equal the amount of serfs that are fleeing, and will be subtracted from the amount of serfs.

Next up, the code calculates some revenue streams. Each marketplace generates a revenue of 75, the military has some randomness to it: 55 is the lower limit, 304 is the upper limit of revenue. Soldiers will cost 3 golden florins per soldier.

Finally, the game will check if you will be invaded. However, I think I've spotted an error in the C translation of the original listing.

A small mistake?

The implementation in C does this:

if ((Me->Land / 1000) > Me->Soldiers) { Me->InvadeMe = True; return (3); } if ((Me->Land / 500) > Me->Soldiers) { Me->InvadeMe = True; return (3); }

That seems... redundant? So, let's have a look at the original listing:

4939 ´ INVASION 4940 IF (L(E)/1000)>P(E) THEN 8100 4945 IF (L(E)/500)<P(E) THEN 4980 4950 FOR A=1TOF:IF A=E THEN 4970 4960 IF P(A)>(P(E)*2. 4) THEN 8100 4970 NEXT 4980 INPUT"(PRESS ENTER)";A$ 4990 RETURN

Mhm. 8100 jumps to the invasion. So - if your ratio of soldiers to land falls under 1 to 1000, than you will be invaded. If your ratio falls down to 1 to 500, you MIGHT get invaded, if another player has at least 2.5 times the amount of soldiers you have. That kind of makes sense.

And indeed, the c version accounts for that:

if (Me->InvadeMe == True) { for (i = 0; i < HowMany; i++) if (i != Me->WhichPlayer) if (MyPlayers[i].Soldiers > (Me->Soldiers * 2.4)) { AttackNeighbor(&MyPlayers;[i], Me); i = 9; } if (i != 9) AttackNeighbor(Baron, Me); }

Our version does the same. However, to circle back to the original question: Yes, that check is redundant, because the differentiation is not done at that place. Which is fine, but that means we can just check for one condition being fulfilled.

And with that, we can wrap up this whole function, as we already dipped our toe into the logic around AttackNeighbor

AttackNeighbor

This function is not too difficult to disassemble. It first checks if the attacker provided is our "Baron". As he does not participate in the game logic, he will randomly take between 1000 and 9999 from you.

Otherwise, the attacker will receive (attacker.soldiers * 1000) - (attacker.Land / 3) amount of land from the player. Now, if the amount of land taken is bigger than this: opponent.Land - 5000, than land taken will be changed to (opponent.Land - 5000) / 2

An invasion will also claim the life of your soldiers. How many, you ask? Well, random is your fate. A random number between 0 and 40 will be the amount of dead soldiers. However, your opponent needs to have at least 15 more soldiers than the random amount selected. If that's not the case, the amount of dead soldiers will be Attacker.Soldiers - 15

And with that, the Harvest Phase is concluded. Phew!

Tax Phase

Ah, yes. Taxes. Finally. Time to generate some revenue. This phase consists of the following methods:

That's correct: It only has one function.

AdjustTax

This function consists of several subcalls to other functions, but in its' heart, it allows you enter new values for your taxes and your justice, and gives you an overview about the current taxes:

private void AdjustTax(Player player) { string result = string.Empty; int val = 1; int duty = 0; while (val != 0 || result[0] != 'q') { Console.WriteLine("{0} {1}", player.Title, player.Name); GenerateIncome(player); Console.WriteLine("({0}%)\t\t({1}%)\t\t({2}%)", player.CustomsDuty, player.SalesTax, player.IncomeTax); Console.WriteLine("1.Customs Duty, 2.Sales Tax, 3.Wealth Tax, "); Console.WriteLine("4. Justice"); Console.Write("Enter tax number for changes, q to continue: "); result = Console.ReadLine() ?? "q"; if (result.ToLower() == "q") { val = 0; continue; } val = Convert.ToInt32(result); Console.WriteLine(); switch (val) { case 1: Console.Write("New customs duty (0 to 100): "); result = Console.ReadLine() ?? "0"; duty = Convert.ToInt32(result); if (duty > 100) duty = 100; if (duty < 0) duty = 0; player.CustomsDuty = duty; break; case 2: Console.Write("New sales tax (0 to 50): "); result = Console.ReadLine() ?? "0"; duty = Convert.ToInt32(result); if (duty > 50) duty = 50; if (duty < 0) duty = 0; player.SalesTax = duty; break; case 3: Console.Write("New income tax (0 to 25): "); result = Console.ReadLine() ?? "0"; duty = Convert.ToInt32(result); if (duty > 25) duty = 25; if (duty < 0) duty = 0; player.IncomeTax = duty; break; case 4: Console.WriteLine("Justice: 1. Very fair, 2. Moderate"); Console.Write(" 3. Harsh, 4. Outrageous: "); result = Console.ReadLine() ?? "1"; duty = Convert.ToInt32(result); if (duty > 4) duty = 4; if (duty < 1) duty = 1; player.Justice = duty; break; } } AddRevenue(player); if (player.IsBankrupt) SeizeAssets(player); }
GenerateIncome

This function is the first of many subroutines, and will on the one hand print out the amount of justice in a human readable fashion, and on the other hand calculate all tax and justice related revenue streams.

JusticeRevenue is calculated by multiplying Justice with 300 and subtracting 500 from that value. The result of that operation is then multiplied with the title of the player, generating more income the higher the rank of the player becomes. That also means that a higher justice value will generate more income at the price of a random amount of people fleeing your city at some point.

CustomsDutyRevenue, SalesTaxRevenue and IncomeTaxRevenue all contain a common factor in the calculations, y. This factor is calculated by subtracting the sales tax, customs duty and income tax from 150, and then dividing that value by 100. If y turns below 1.0, the value is locked to 1.0. It's then divided by 100

In essence, this means for the customs duty revenue, that high taxes will negate the effect merchants have on the revenue stream. For sales tax, that eliminates the effect justice has, and for income tax, it eliminates the effect justice and nobles have on the revenue. That is, because y is a float value, but get's casted to integer, thus eliminating the fraction and be either 1 or 0.

player.CustomsDutyRevenue = player.Nobles * 180 + player.Clergy * 75 + player.Merchants * 20 * Convert.ToInt32(y); player.CustomsDutyRevenue += Convert.ToInt32(player.PublicWorks * 100.0); player.CustomsDutyRevenue = Convert.ToInt32((Convert.ToDouble(player.CustomsDuty) / 100.0 * Convert.ToDouble(player.CustomsDutyRevenue))); player.SalesTaxRevenue = player.Nobles * 50 + player.Merchants * 25 + Convert.ToInt32(player.PublicWorks * 10.0); player.SalesTaxRevenue *= Convert.ToInt32(y * (5 - player.Justice) * player.SalesTax); player.SalesTaxRevenue /= 200; player.IncomeTaxRevenue = player.Nobles * 250 + Convert.ToInt32(player.PublicWorks * 20.0); player.IncomeTaxRevenue += (10 * player.Justice * player.Nobles * Convert.ToInt32(y)); player.IncomeTaxRevenue *= player.IncomeTax; player.IncomeTaxRevenue /= 100;
AddRevenue

This functions adds the revenue streams that we calculated in the previous method to your treasury. Now, it is possible to spend more money than you own, and even your taxes revenue might not add enough money to the treasury to turn your account positive. If your treasury is negative, the game will penalize you by subtracting another 50% of your deficit from your treasury. Your rank dictates, how much deficit is allowed, by multiplying your rank with -10000. If your treasury is below that value, the game will set the IsBankrupt flag on you.

SeizeAssets

If you are bankrupt, the game will seize your assets. That's pretty straight forward, as the game will basically reset all your possessions.

private void SeizeAssets(Player player) { string result = string.Empty; player.Marketplaces = 0; player.Palace = 0; player.Cathedral = 0; player.Mills = 0; player.Land = 6000; player.PublicWorks = 1.0; player.Treasury = 100; player.IsBankrupt = false; Console.WriteLine(); Console.WriteLine("{0} {1} is bankrupt.", player.Title, player.Name); Console.WriteLine(); Console.WriteLine("Creditors have seized much of your assets."); Console.WriteLine("(Press ENTER): "); Console.ReadLine(); return; }

However, that also means that your game won't end with bankruptcy. Not immediately, that is.

Map Phase

This one is currently not implemented in the c code, and as such, I haven't implemented it yet in my version. Essentially, this will draw a map of your kingdom. You will see the amount of marketplaces, cathedrals and such that you have in your possessions. Later implementations of the game featured more elaborate maps, containing only one of each buildings (mill, cathedral, palace, marketplace), and adding more and more extensions with each level.

The map also displays a serf that moves north towards the wall of your town. If this serf is hitting that wall, it is time for you to buy new land, signalling that there are more people in your town than land is available.

Public Works Phase

In this phase, the game will allow you to buy new assets for your town, or recruit soldiers. The code is not that interesting, so I decided to use a table to lay down the effects.

Asset Treasury PublicWorks Clergy Nobles Merchants Serfs
Market -1000 +1.0 +5
Mill -2000 +0.25
Palace -3000 +0.5 Random(2)
Cathedral -5000 +1.0 Random(6)
Soldiers -500 -20a

a: 20 serfs will be converted to 20 soldiers.

This concludes all four phases of the game.

Progress calculation

The game will calculate your progression on a large amount of factors. Two functions are important for that:

private int Limit10(int num, int denom) { int val = num / denom; return (val > 10 ? 10 : val); } private bool CheckNewTitle(Player player) { int total = 0; /* Tally up our success so far . . . . */ total += Limit10(player.Marketplaces, 1); total += Limit10(player.Palace, 1); total += Limit10(player.Cathedral, 1); total += Limit10(player.Mills, 1); total += Limit10(player.Treasury, 5000); total += Limit10(player.Land, 6000); total += Limit10(player.Merchants, 50); total += Limit10(player.Nobles, 5); total += Limit10(player.Soldiers, 50); total += Limit10(player.Clergy, 10); total += Limit10(player.Serfs, 2000); total += Limit10(Convert.ToInt32(player.PublicWorks * 100.0), 500); player.TitleNum = (total / player.Difficulty) - player.Justice; if (player.TitleNum > 7) { player.TitleNum = 7; } if (player.TitleNum < 0) { player.TitleNum = 0; } /* Did we change (could be backwards or forwards)? */ if (player.TitleNum > player.OldTitle) { player.OldTitle = player.TitleNum; ChangeTitle(player); Console.WriteLine(); Console.WriteLine("Good news! {0} has achieved the rank of {1}", player.Name, player.Title); Console.WriteLine(); return true; } //Revert to the old title. You won't loose a rank here. player.TitleNum = player.OldTitle; return false; }

The game will limit each step from line 11 to 22 by ten, using different denominators for each value. In line 23, the difficulty that got selected at the beginning of the game is used as a divisor for the result of this operation. A high justice value will furthermore reduce the title progression.

Final steps

The next steps will conclude the current turn. The year will be increased, and compared against the predetermined player's death year. If the year has come, the ImDead function will set the ImDead flag and print a small obituary. If the player has reached the highest title number, the IWon flag is set. In both cases, in a single player game, the game ends.

player.Year++; if (player.Year == player.YearOfDeath) { ImDead(player); } if (player.TitleNum >= 7) { player.IWon = true; }

Final thoughts

That was more to unpack than I anticipated in the beginning. But it was kind of fun, anyways. The game is surprisingly complex, if you consider it being almost 50 years old (in 2025, at the time of writing). So, what should we do from here on? Well, on the one hand, there is a lot of things to add. You could modify the code to finally print a map. You could go ahead and tweak the variables, remove some kind of randomness and add some more layers of simulation. You could also go ahead and implement a version that uses graphics. The options are endless. I, for my part, am thinking about building a game like this set in the age and time of Cyberpunk.

Make sure to check out my conversion of the game over on Github, and find the downloadable versions in the Release Section.