A game character takes 30 damage, reducing their health from 100 to 70. A variable and a subtraction can handle that. But what happens when there are two characters? Where should we handle damage that exceeds the remaining health, or a negative value passed by mistake?
This article introduces object-oriented programming through a small character health program. In this first installment, we will group data with operations, create separate characters from the same definition, and put the rules for changing health in one place.
The examples use Kinmokusei, a programming language I am developing. It uses TypeScript-inspired syntax and compiles to Go source code. We will explain the class and method syntax as we use it. You do not need prior Kinmokusei experience, but you should be familiar with variables, functions, and if statements.
We will start by managing HP with variables and a function, then gradually rewrite the program using a class. At the end of this article, you will find the complete program and instructions for running it.
Start with variables and a function
Begin with a character's name and current health:
const heroName = "Aki";
let heroHp: int = 100;
heroName holds the name; heroHp holds the current hit points, or HP. int means an integer. We use let for HP because we will assign a new value, and const for a name we do not reassign in this example.
We can put the damage calculation in a function:
function damage(hp: int, amount: int): int {
if (amount <= 0) {
return hp;
}
if (amount >= hp) {
return 0;
}
return hp - amount;
}
hp is the current health, and amount is the damage. The final : int says the function returns an integer. Zero or negative damage leaves health unchanged; damage at least as large as the remaining HP returns zero. Otherwise, we subtract.
The function returns a new number. To update the caller's variable, assign that result:
heroHp = damage(heroHp, 30);
// heroHp is now 70.
With two characters, the code inside main might look like this:
const heroName = "Aki";
let heroHp: int = 100;
const partnerName = "Haru";
let partnerHp: int = 100;
heroHp = damage(heroHp, 30);
fmt.Println(heroName, heroHp);
fmt.Println(partnerName, partnerHp);
fmt.Println prints values so we can inspect them. The complete program later includes the import from Go's standard library. This example prints:
Aki 70
Haru 100
A function already lets us share the damage calculation without introducing a class. However, names and health values are separate variables. The caller keeps track of which values belong together. Writing partnerHp = damage(heroHp, 30) would assign a result based on the hero's health to the partner. Both values are integers, so their types alone cannot catch this mix-up.
Let's try grouping a character's name and health together, then asking that character to take damage.
Define a character with a class
An object, in this example, holds state such as a name and HP and offers operations using that state. A class defines what those objects hold and which operations they provide.
Start with a class named Character:
class Character {
public name: string;
private hp: int;
constructor(name: string) {
this.name = name;
this.hp = 100;
}
}
Variables belonging to an object, such as name and hp, are called fields. string represents text, and int represents an integer. public permits access from outside the class; private restricts access to the class's implementation. We expose the name while keeping health under the class's control. The public name field can also be changed from outside in this version.
constructor contains the initialization code that runs when an object is created. Here, it stores the supplied name in a field and sets HP to 100. This initialization mechanism is called a constructor.
this refers to the object being initialized or operated on. In this.name = name, the left side is the object's field; the right side is the constructor argument. Their names match, but they serve different roles.
new Character("Aki")
|
+-- constructor argument name receives "Aki"
this.name = name -> store the name "Aki"
this.hp = 100 -> start at 100 HP
Create two separate instances
Writing class Character defines the type; it does not create the hero or partner. Use new to create actual objects:
const hero = new Character("Aki");
const partner = new Character("Haru");
An individual object created from a class is an instance of that class. hero and partner each refer to a Character instance. Calling new twice creates two characters, each with their own name and HP.
Character class
Defines the name and HP fields
|
+-- new Character("Aki") -> hero -> Aki / HP 100
+-- new Character("Haru") -> partner -> Haru / HP 100
const means we cannot reassign the variable to a different object. It does not freeze the character's HP. Also, const other = hero would not create a new character: it would add another reference to the same one. The separate new calls are what create separate state.
Add methods that work on the character
Next, add the damage operation inside Character. A function defined on a class and called on an object is a method. Insert these two methods after the constructor, before the class's closing brace:
public function takeDamage(amount: int): void {
if (amount <= 0) {
return;
}
if (amount >= this.hp) {
this.hp = 0;
return;
}
this.hp = this.hp - amount;
}
public function getHp(): int {
return this.hp;
}
takeDamage receives only the damage amount. The target object already holds its current health in this.hp. Its return type, void, means it does not return a value to the caller. Instead, it updates the object's state.
getHp returns the current HP as an integer. It lets callers inspect health without changing it.
To damage the hero, call:
hero.takeDamage(30);
The expression before the dot, hero, identifies the target. During this method call, this refers to the character referenced by hero. Only that character's health changes:
Before After hero.takeDamage(30)
hero -> Aki / HP 100 hero -> Aki / HP 70
partner -> Haru / HP 100 partner -> Haru / HP 100
The function version used heroHp = damage(heroHp, 30): pass in the current value and assign the result. The method version uses hero.takeDamage(30): identify an object and ask it to perform an operation. We can still call a method on the wrong character, but the relationship between a name, its HP, and the health-changing operation is now part of the program's structure.
Keep health changes within the rules
Why make hp private? Code outside the class cannot directly write:
// This is a compile error. Do not add it to the finished program.
hero.hp = -999;
Callers use hero.takeDamage(...) instead. That entry point applies these rules:
| Damage amount | Action |
|---|---|
| Zero or negative | Leave HP unchanged |
| At least the remaining HP | Set HP to zero |
| Any other positive amount | Subtract it from HP |
If a character has 70 HP and takes 200 damage, we compare before subtracting and set HP to zero. Subtracting a negative number would otherwise increase health, so the first condition returns without making a change. Starting at 100 in the constructor and updating only through this method keeps HP between 0 and 100.
Grouping state with the operations that manage it, and defining what callers can access, is encapsulation. Merely writing private does not automatically enforce a rule. The class must implement valid state changes and provide the operations through which callers use them.
This small program could also be designed with functions and separate data structures. The class expresses a particular grouping: whose state we are working with, and how that state may change. We can explore this part of object-oriented programming without introducing inheritance.
Save and run the complete program
Create a directory named character-health and save the following code as main.km. This assembles the pieces above and adds a main function to exercise them.
character-health/
└── main.km
import go fmt from "fmt" imports Go's standard formatting library. We use fmt.Println to print the results. Execution starts in main.
import go fmt from "fmt";
class Character {
public name: string;
private hp: int;
constructor(name: string) {
this.name = name;
this.hp = 100;
}
public function takeDamage(amount: int): void {
if (amount <= 0) {
return;
}
if (amount >= this.hp) {
this.hp = 0;
return;
}
this.hp = this.hp - amount;
}
public function getHp(): int {
return this.hp;
}
}
function main(): void {
const hero = new Character("Aki");
const partner = new Character("Haru");
fmt.Println(hero.name, hero.getHp());
fmt.Println(partner.name, partner.getHp());
hero.takeDamage(30);
fmt.Println("After 30 damage:", hero.name, hero.getHp());
fmt.Println("Partner:", partner.name, partner.getHp());
hero.takeDamage(200);
fmt.Println("After 200 damage:", hero.name, hero.getHp());
hero.takeDamage(-10);
fmt.Println("After negative damage:", hero.name, hero.getHp());
}
Follow the official installation guide to install Kinmokusei's CLI and its supported Go toolchain. The CLI is named keika. From character-health, run:
keika version
keika check main.km
keika run main.km
check validates the source; run compiles and executes it. I ran this sample with keika v0.4.3 and go1.23.5 linux/amd64, producing:
Aki 100
Haru 100
After 30 damage: Aki 70
Partner: Haru 100
After 200 damage: Aki 0
After negative damage: Aki 0
Both characters start at 100 HP. After the hero takes 30 damage, their HP becomes 70 while the partner stays at 100. Another 200 damage reduces the hero's HP to zero, and the negative damage leaves it at zero. The output makes both independent instance state and the update rules visible.
Try changing hero.takeDamage(30) to partner.takeDamage(30). Which character's HP changes now? Then test damage amounts of 0, 100, and 101 on a newly created character with 100 HP to exercise the different branches.
Connect the vocabulary to the code
| Term | Meaning in this program |
|---|---|
| Class |
Character, the definition of state and operations |
| Object or instance | An individual character created with new Character(...)
|
| Field | State held by each character, such as name or hp
|
| Constructor |
constructor, which sets the name and initial HP |
| Method | An operation called on a character, such as takeDamage or getHp
|
| Encapsulation | Restricting direct HP access and updating it through defined operations |
Adding healing gives us a next step. If healing must not exceed maximum HP, what state should the character hold, and where should that check live? Before scattering conditions across callers, try expressing healing as another operation the character provides.
Top comments (0)