Heartbeat Horror is a game I created for school in my second year, utilizing a heartbeat-sensor and micro:bit as special input & output.
Time period | Engine & language(s) | Team size | GitHub link |
8-9 weeks | Unity, C#, Python | 1 | here |
Gameplay
The game is a simple maze, where the player has to find the exit – but there is a monster trying to catch them. Fortunately, the monster is blind. Unfortunately, the monster can hear your heartbeat – the higher it goes, the better it can find you, so make sure to stay calm. The player has a physical radar (the micro:bit) to help them know how far away the monster is.
Process
Game Loop

The main game loop is as follows: the player sees the monster getting closer on their radar; player gets nervous and heartbeat goes up; the monster can get closer to the player; etc.
I chose to design the game this way, to keep adding tension, to make the game really feel like a horror game. It also forces the player to focus on their breathing, to stay calm and keep their heart rate down. After some play testing, this is really the only way to be able to find the exit. I found this somewhat makes up for the rest of the game-play being very simple, because the player needs to focus most of their attention on their own body.
Research
We were given access to school-owned devices for special input and/or output. However, I chose for buying my own heart rate monitor, as the school-owned heart-rate monitors had to be put on the finger, and I wanted to give the player as much immersion as possible.
This did mean that I had to research how to use Bluetooth Low Energy, which I had no previous experience with. I had found a program that was able to read out the heart rate from the device I used (device here, program here) but I wanted to try and figure out how to do this myself, as that felt like the point of the project.
Reading heart rate data
This is what took me the longest time to figure out – at first, I really wanted to integrate the heart rate reading into Unity so it could ship directly with the game. Unfortunately there was very little info I could find about this, and after some attempts, I decided to write a separate program in Python. It connects with the device, and then displays the heart rate detected, as well as export it to a text file. The game let’s the player select the file, and then reads the heart rate from the file every time it changes. Here is the Python program in action, as well as the code for it:

View code
def notification_handler(sender, data):
output_numbers = list(data)
print(output_numbers[1])
#Store data in text file
file = open('heartrate.txt', 'w')
file.write(str(output_numbers[1]))
file.close()
async def main():
uuid_hr_service = "0000180d-0000-1000-8000-00805f9b34fb"
uuid_hr_characteristic = "00002a37-0000-1000-8000-00805f9b34fb"
inputDeviceName = input('Enter device name: ')
selectedDevice = await BleakScanner.find_device_by_name(inputDeviceName)
name = selectedDevice.name
address = selectedDevice.address
print('Found device: ' + name + '\nAddress: ' + address)
print('Connecting to device ' + name + ' with address ' + address + ' ...')
async with BleakClient(address, winrt=dict(use_cached_services=True)) as client:
if client.is_connected:
print('Connected!')
#Loop to receive heart rate notification
while True:
await client.start_notify(uuid_hr_characteristic, notification_handler)
await asyncio.sleep(1.0)
await client.stop_notify(uuid_hr_characteristic)
#Run main function through asyncio.run()
asyncio.run(main())
Micro:bit as a radar
The micro:bit was the easier part of the project. All I had to do was let it listen for an integer over the serial port, and display an “image” on it’s screen. The calculating for the “distance level” is all done by the game itself. That level is determined by the distance between the monster and player, walked by a Navmesh agent. Here you can see the code for the micro:bit, and the code for calculating the distance level:
View code
def on_data_received():
global danger, splitData
data2 = serial.read_line().replace("\n", "")
if data2.includes("READY"):
READY.show_image(0)
if data2.includes("0"):
level0IMG.show_image(0)
danger = False
if data2.includes("1"):
level1IMG.show_image(0)
danger = False
if data2.includes("2"):
level2IMG.show_image(0)
danger = False
if data2.includes("3"):
level3IMG.show_image(0)
danger = False
if data2.includes("4"):
level4IMG.show_image(0)
danger = True
serial.on_data_received(serial.delimiters(Delimiters.NEW_LINE), on_data_received)
danger = False
level4IMG: Image = None
level3IMG: Image = None
level2IMG: Image = None
level1IMG: Image = None
level0IMG: Image = None
READY: Image = None
data = ""
splitData: List[str] = []
music.set_volume(20)
READY = images.create_image("""
. . . . #
. . . # #
# . # # .
# # # . .
. # . . .
""")
level0IMG = images.create_image("""
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
""")
level1IMG = images.create_image("""
. . . . .
. . . . .
. . # . .
. . . . .
. . . . .
""")
level2IMG = images.create_image("""
. . . . .
. . # . .
. # # # .
. . # . .
. . . . .
""")
level3IMG = images.create_image("""
. . # . .
. # # # .
# # # # #
. # # # .
. . # . .
""")
level4IMG = images.create_image("""
# # # # #
# # # # #
# # # # #
# # # # #
# # # # #
""")
def on_forever():
if danger == True:
music.play_tone(523, music.beat(BeatFraction.SIXTEENTH))
music.rest(music.beat(BeatFraction.SIXTEENTH))
basic.forever(on_forever)
View code
private IEnumerator SetHRBasedPosition(float distance)
{
randDir = new Vector3(Random.Range(-1, 2), 0, Random.Range(-1, 2));
newPos = randDir * distance + player.transform.position;
pathDist = Radar.CalculatePathDistance(newPos, player.transform.position);
if (pathDist >= distance * minDistance && pathDist <= distance * maxDistance)
{
transform.position = newPos;
failedTries = 0;
yield return null;
}
else
{
yield return new WaitForSeconds(0.01f);
if (failedTries < maxFailedTries)
{
failedTries++; //Add to the failed tries
StartCoroutine(SetHRBasedPosition(calculatedDistance));
yield return null;
}
else if (failedTries >= maxFailedTries) //If the failed tries has exceeded the maximum failed tries
{
transform.position =
new Vector3(player.transform.position.x, 0, player.transform.position.z + distance);
failedTries = 0;
yield return null;
}
}
yield return null;
}
private float CalculateDistance(float b, float g, float m)
{
//a = normal heartrate * 0.966^(normal heartrate * multiplier)
return b * Mathf.Pow(g, b * m);
}
Calculating monster-player distance
The distance of how close the monster can get to the player is determined by the heart rate. The formula for calculating the distance is as follows:
distance = normalHeartrate * 0.985^(normalHeartrate*multiplier)
This is an exponential formula, where the distance is calculated based on the normal heart rate (heart rate while resting) multiplied by the growth factor (more or less the sensitivity of the distance intervals) which is to the power of the resting heart rate multiplied by the multiplier. The multiplier is simply a number that represents how many times higher the live heart rate is compared to the resting heart rate.
The multiplier is calculated as such:
multiplierHigher = (heartrate / normalHeartrate);