Hearth’s Light Potion Shop
See the Steam Page!

Completing a tough order.
Hearth’s Light Potion Shop is a relaxing shop management game. The true focus of the game is on the robust ingredient and potion making system that plays like a mix of a solitaire card game and a satisfying puzzle game. The surrounding systems exist to support the main gameplay of making potions.
Heart’s Light was my first commercial game released. Commercial release was my intention from when I first incepted the game. I created a very specific plan before I even opened the Editor and, for the most part, I stuck to it.
The basic gameplay loop involves greeting customers that enter your shop and taking their potion orders. A customer will either have a relatively simple order that they need immediately or a more complex order that they’ll return later for. Completed orders grant coin based on how well you fulfilled them. At the end of each day you order more ingredients. As the game progresses you’ll unlock new types of potions and be able to spend gold on shop upgrades. Finally, each customer has a relationship meter that can trigger special abilities and interactions.
Genre: Casual Shop Management
Timeline: Primary development between January 2023 and July 2023
Engine: Unity
Platform: Windows, Mac
Team size: 3
Responsibilities: Programming, Design, 3D modelling
Steam Page: https://store.steampowered.com/app/2282400/Hearths_Light_Potion_Shop/
Github Selected Scripts: https://github.com/WillyBMiles/Hearth-s-Light-Potion-Shop-Selected-Scripts
Highlights
- Players purchase unique ingredients that they put together in a way where order matters. Each potion feels like a puzzle, but the fact that not every potion order is completable makes it all the more satisfying when you can.
- The ingredient system is polymorphic, slotting together abstract results and conditions to allow a high level of complexity with a deceptively simple backend.
- The player’s interactions with their customers affect their relationships. These allow customers to give tips, but also unlock new interactions like asking for a refund or declining an order if they dislike the player. Customers can also give the player gifts and special quests if the customer likes them.
Challenges
- As my first released game my biggest challenges revolved around everything beyond making the game itself. I had done plenty of coding and 3D modelling before but I had never edited a trailer. I didn’t know what to put on a Steam page or how to market the game. Hearth’s Light became a crash course in all of the aspects of releasing a game that I never thought about and allowed me to learn a bunch of those useful skills.
- Similarly, I knew Hearth’s Light had to be more polished than any previous game. It needed to stand up to scrutiny from the most critical people in the world: randoms on the internet. This meant it had to have cleaner looking graphics without any programmer art or temporary UI. It also meant it needed a real options menu and a helpful tutorial. Overall, I was glad I left plenty of time before the deadline for all the little things that need to go into an actual release.
- Throughout this project I had to constantly resist the temptation of scope creep without compromising the design of the game. I always knew the game would exist just within the potion shop so that I could feasibly complete the game within the deadline. I originally dabbled with the idea of having a backroom or bedroom, but realized that it might overcomplicate the clean design as well as add extra work for me. On the contrast, the original idea had a much simpler interaction with the customers. I realized that a more interesting interaction system would help the game and not be too difficult to implement, so I shifted gears.
System Spotlight: Ingredients
Two main abstract types, Result and Condition, make up the backbone of the ingredient system. The concrete Effect class contains a list for each of these types, applying the result if the conditions are met. Each Ingredient then contains multiple lists of Effect objects. Each list represents a different method of interaction. For example, all Add effects trigger when the ingredient is first added to the cauldron and all Brew effects occur when you finally brew the cauldron to create a potion.
Certain triggers are special. Each potion can only be Fired once to trigger Fire effects and although you can Stir a cauldron as much as you want, when a new ingredient is added, all Stir effects are removed. This means that the order of ingredients matters just as much as the ingredients themselves, exponentially increasing the ways to combine your set of ingredients.
public enum Type
{
Fire,
Cauldron,
Add,
Brew,
Stir
}
public abstract class Result
{
public abstract void Apply(MixInfo mix, EffectInfo effectInfo);
}
Result is the base class for everything that happens to a potion. It’s only a single abstract function that applies the result to a cauldron (represented by a MixInfo).
public class Loop : Result
{
[HideIf(nameof(condition))]
public int numberOfTimes = 1;
[HideIf(nameof(condition))]
public Multiplier mult = null;
public Condition condition = null;
public List<Result> results = new List<Result>();
public override void Apply(MixInfo mix, EffectInfo effectInfo)
{
int num = numberOfTimes;
if (mult != null)
{
num = mult.Multiply(num, mix);
}
if (condition != null)
{
while (condition.Check(mix))
{
foreach (Result result in results)
{
result.Apply(mix, effectInfo);
}
}
}
else
{
for (int i = 0; i < num; i++)
{
foreach (Result result in results)
{
result.Apply(mix, effectInfo);
}
}
}
}
}
Loop is a recursive Result that can trigger other results more than once. It is mostly a convenience for applying the same Result multiple times without having to make that a basic part of the abstract Result class, acting similar to the Decorator design pattern.
Spotlight: Ingredient Design
I very intentionally designed ingredients to work well together. The basis of the ingredient system is flavors and potency. Every order requires potency and gives extra coin based on the amount of potency in the potion. Occasionally, orders require particular flavors but usually flavors are just a means to the end of adding potency.
Each of the 6 flavors has an ingredient that provides that flavor and nothing else, and an ingredient with the main purpose of converting that flavor into potency. They all work a bit differently and interact with different systems, but fundamentally they turn one flavor directly into potency.


Some basic early game ingredients.

A somewhat complex order with an extra condition for unique flavors.
As the game progresses you’ll passively unlock more ingredients. The order of ingredient unlock is carefully chosen to slowly first introduce all of the basic ingredients. The basic ingredients then naturally introduce all of the ways to interact with a cauldron. These points are reinforced by tips that pop up as the ingredient types are used.
After the basic ingredients are introduced, more complicated effects become available, including those with drawbacks and very situational effects. Though I created a balancing framework to ensure no ingredient felt overpowered, I didn’t worry about it too much. Each ingredient is mostly one-time use and players don’t get much choice in which ingredients they get, so it’s okay that some ingredients are objectively more powerful than others.


More complicated and situational ingredients.
static List<Property> producers = new();
static List<Property> consumers = new();
static List<Property> conditionConsumers = new();
public static int GetScore(List<IngredientInfo> infos)
{
producers.Clear();
consumers.Clear();
conditionConsumers.Clear();
int difference = 0;
int utility = 0;
bool gottenPotency = false;
foreach (IngredientInfo info in infos)
{
if (info == null)
continue;
producers.AddRange(info.producers);
consumers.AddRange(info.consumers);
if (info.classification == IngredientInfo.Classification.Flavor)
difference++;
if (info.classification == IngredientInfo.Classification.Potency)
{
difference--;
gottenPotency = true;
}
if (info.classification == IngredientInfo.Classification.Utility)
{
utility++;
}
}
int score = 0;
foreach (Order order in OrderController.instance.futureOrders)
{
if (order.Conditions != null)
{
foreach (Condition condition in order.Conditions)
{
conditionConsumers.AddRange(condition.requires);
if (condition.requires.Count > 0)
{
for (int i = 0; i < condition.howManyMissingRequires; i++)
{
//remove a few random consumers
conditionConsumers
.Remove(condition
.requires[Random.Range(0, condition.requires.Count)]);
}
}
}
}
}
int missedConsumers = 0;
int filledConsumers = 0;
int remainingFlavors = 0;
foreach (Property property in consumers)
{
if (property == Property.AnyFlavor)
{
remainingFlavors++;
}
else if (property == Property.Potency)
{
if (difference < 0)
difference++; //counts as a negative potency, if there are too many
}
else
{
if (producers.Contains(property))
{
producers.Remove(property);
filledConsumers++;
}
else if (producers.Contains(Property.AnyFlavor))
{
producers.Remove(Property.AnyFlavor);
filledConsumers++;
}
else
missedConsumers++;
}
}
//Lose points if you can't fill condition consumers
foreach (Property property in conditionConsumers)
{
if (property == Property.AnyFlavor)
{
if (producers.Count > 0)
producers.RemoveAt(0);
else
{
score -= infos.Count;
break;
}
}
else
{
if (producers.Contains(property))
{
producers.Remove(property);
}
else
{
score -= infos.Count;
break;
}
}
}
score -= Mathf.Max(0, remainingFlavors - producers.Count); //missing "any flavor" consumers
if (!gottenPotency) //need at least 1 potency or you lose 7 points
score -= 7;
score -= Mathf.Abs(difference); //lose a point for each imbalance between potency and flavor
score -= Mathf.Max(0, producers.Count - remainingFlavors) / 2; //lose half a point for missing producers
if (difference > 0)
{
score -= 2; //if there are fewer potency than flavor, lose 2 more points
}
score += filledConsumers - missedConsumers; //filled consumers gain a point, missed consumers lose a point
score += utility / 2; //utility counts for 1/2 a point
return score;
}
Calculate the score for a set of ingredients. The highest scored set of ingredients gets given to the player.
The game determines which ingredients to give the player when they order new ones each day by randomly generating sets of ingredients and then scoring each of them. The highest scoring set of ingredients is then given to the player. This code generates that score when given a set of ingredients.
Every set of ingredients must produce potency in some way, otherwise it loses 7 points. To make sure that ingredients make sense together when there are so many that have unique and situational effects, every ingredient defines which flavors it consumes and which it produces. For each consumer or producer that is left unfulfilled the set loses score, so ingredients that require a specific flavor score higher when they appear with producers of that flavor. Additionally, a set loses score if it produces too much potency or too much flavor compared to the other, they should be as similar as possible (as represented by the difference
variable).
Most importantly, the system takes into account every order the player currently has unfulfilled and removes points if the given ingredients can’t create a potion that fulfills those orders. This helps alleviate the frustrating moment when you just don’t get what you need to complete the order and it feels like you just got unlucky.
The beginning of a playthrough of Hearth’s Light, including the tutorial.
Release trailer for Hearth’s Light.