DEV Community

puffball1567
puffball1567

Posted on

Object-Oriented Programming for Beginners: Build a Character Health System

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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)