Core Bluetooth Tutorial for iOS: Heart Rate Monitor

Jawwad Ahmad
Update note: This tutorial has been written for Xcode 9 & iOS 11 by Jawwad Ahmad. The original Objective-C version was written by Steven Daniel.
Learn how to use Core Bluetooth on a real-world device!

Learn how to use Core Bluetooth on a real-world device!

Given the proliferation of gadgets in today’s world, communication between those devices can lead to using those gadgets, and the information provided by those gadgets, in more effective ways. To this end, Apple has introduced the Core Bluetooth framework, which can communicate with many real-world devices such as heart rate sensors, digital thermostats, and workout equipment. If you can connect to it via BLE (Bluetooth Low Energy) wireless technology, the Core Bluetooth framework can connect to it.

In this tutorial, you’ll learn about the key concepts of the Core Bluetooth framework and how to discover, connect to, and retrieve data from compatible devices. You’ll use these skills to build a heart rate monitoring application that communicates with a Bluetooth heart rate sensor.

The heart rate sensor we use in this tutorial is the Polar H7 Bluetooth Heart Rate Sensor, but any other Bluetooth heart rate sensor should work as well.

First, let’s take a moment to go over a few Bluetooth-specific terms: centrals, peripherals, services, and characteristics.

Centrals and Peripherals

A Bluetooth device can be either a central or peripheral:

  • Central: the object that receives the data from a Bluetooth device.
  • Peripheral: the Bluetooth device that publishes data to be consumed by other devices.

In this tutorial, the iOS device will be the central, receiving heart rate data from the peripheral.

Advertising Packets

Bluetooth peripherals broadcast some of the data they have in the form of advertising packets. These packets can contain information such as the peripheral’s name and main functionality. They can also include extra information related to what kind of data the peripheral can provide.

The job of the central is to scan for these advertising packets, identify any peripherals it finds relevant, and connect to individual devices for more information.

Services and Characteristics

Advertising packets are very small and cannot contain a great deal of information. To share more data, a central needs to connect to a peripheral.

The peripheral’s data is organized into services and characteristics:

  • Service: a collection of data and associated behaviors describing a specific function or feature of a peripheral. For example, a heart rate sensor has a Heart Rate service. A peripheral can have more than one service.
  • Characteristic: provides further details about a peripheral’s service. For example, the Heart Rate service contains a Heart Rate Measurement characteristic that contains the beats per minute data. A service can have more than one characteristic. Another characteristic that the Heart Rate service may have is Body Sensor Location, which is simply a string that describes the intended body location of the sensor.

Each service and characteristic is represented by a UUID which can be either a 16-bit or a 128-bit value.

Getting Started

First, download the starter project for this tutorial. It’s a very simple app to display the intended body sensor location and heart rate. The starter project has placeholders for the data to be retrieved from the heart rate monitor.

Starter Project

Starter Project

Starter Project

Final Project

Note: The iOS Simulator doesn’t support Bluetooth – you’ll need to build and run on an actual device.

Before you start coding, you’ll need to set the Team for your project. Select the project root in the project navigator, select the HeartRateMonitor target, and in the General ▸ Signing section, set the Team to your Apple ID. (You might also need to set the bundle Identifier for the project to something else …)

Once that’s done, run the app. If you see an error, you need to navigate to the Settings app on your device, go to General ▸ Device Manangement, and Trust your Apple ID. After that, you will be able to run the app from Xcode on your iOS device.

Preparing for Core Bluetooth

You’ll first import the Core Bluetooth framework. Open HRMViewController.swift and add the following:

import CoreBluetooth

Most of the work in the Core Bluetooth framework will be done through delegate methods. The central is represented by CBCentralManager and its delegate is CBCentralManagerDelegate. CBPeripheral is the peripheral and its delegate is CBPeripheralDelegate.

You’ll lean on Xcode to help you add the required methods. The first thing you’ll do is add conformance to CBCentralManagerDelegate, but you’ll use Xcode’s fix-it feature to add the required protocol method.

Add the following extension to the end of HRMViewController.swift, outside the class:

extension HRMViewController: CBCentralManagerDelegate {

}

You should see an Xcode error appear shortly. Click on the red dot to expand the message and then click Fix to have Xcode add the required protocol method for you.

Would I like to add protocol stubs? Why of course I would, thank you for asking!

Xcode should have added centralManagerDidUpdateState(_:) for you. Add an empty switch statement to the method to handle the various states of the central manager:

switch central.state {

}

In a moment, you’ll see an error stating that switch must be exhaustive. Click on the red dot and click on Fix to have Xcode add all of the cases for you:

Xcode, you are too kind!

Xcode will helpfully add the following code:

You can replace the placeholders with appropriate values from the following code or just replace the whole switch statement if you prefer to cut and paste:

switch central.state {
  case .unknown:
    print("central.state is .unknown")
  case .resetting:
    print("central.state is .resetting")
  case .unsupported:
    print("central.state is .unsupported")
  case .unauthorized:
    print("central.state is .unauthorized")
  case .poweredOff:
    print("central.state is .poweredOff")
  case .poweredOn:
    print("central.state is .poweredOn")
}

If you build and run at this point, nothing will be printed to the console because you haven’t actually created the CBCentralManager.

Add the following instance variable right below the bodySensorLocationLabel outlet:

var centralManager: CBCentralManager!

Next, add the following to the beginning of viewDidLoad() to initialize the new variable:

centralManager = CBCentralManager(delegate: self, queue: nil)

Build and run, and you should see the following printed to the console:

central.state is .poweredOn
Note: If you had Bluetooth turned off on your device, you’ll see central.state is .poweredOff instead. In this case, turn on Bluetooth and run the app again.

Now that the central has been powered on, the next step is for the central to discover the heart rate monitor. In Bluetooth-speak, the central will need to scan for peripherals.

Scanning for Peripherals

For many of the methods you’ll be adding, instead of giving you the method name outright, I’ll give you a hint on how to find the method that you would need. In this case, you want to see if there is a method on centralManager with which you can scan.

On the line after initializing centralManager, start typing centralManager.scan and see if you can find a method you can use:

The scanForPeripherals(withServices: [CBUUID]?, options: [String: Any]?) method looks promising. Select it, use nil for the withServices: parameter and remove the options: parameter since you won’t be using it. You should end up with the following code:

centralManager.scanForPeripherals(withServices: nil)

Build and run. Take a look at the console and note the API MISUSE message:

API MISUSE: <CBCentralManager: 0x1c4462180> can only accept this command while in the powered on state

Well, that certainly makes sense right? You’ll want to scan after central.state has been set to .poweredOn.

Move the scanForPeripherals line out of viewDidLoad() and into centralManagerDidUpdateState(_:), right under the .poweredOn case. You should now have the following for the .poweredOn case:

case .poweredOn:
  print("central.state is .poweredOn")
  centralManager.scanForPeripherals(withServices: nil)
}

Build and run, and then check the console. The API MISUSE message is no longer there. Great! But has it found the heart rate sensor?

It probably has; you simply need to implement a delegate method to confirm that it has found the peripheral. In Bluetooth-speak, finding a peripheral is known as discovering, so the delegate method you’ll want to use will have the word discover in it.

Below the end of the centralManagerDidUpdateState(_:) method, start typing the word discover. The method is too long to read fully, but the method starting with centralManager will be the correct one:

Select that method and replace the code placeholder with print(peripheral).

You should now have the following:

func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
                    advertisementData: [String: Any], rssi RSSI: NSNumber) {
  print(peripheral)
}

Build and run; you should see a variety of Bluetooth devices depending on how many gadgets you have in your vicinity:

<CBPeripheral: 0x1c4105fa0, identifier = D69A9892-...21E4, name = Your Computer Name, state = disconnected>
<CBPeripheral: 0x1c010a710, identifier = CBE94B09-...0C8A, name = Tile, state = disconnected>
<CBPeripheral: 0x1c010ab00, identifier = FCA1F687-...DC19, name = Your Apple Watch, state = disconnected>
<CBPeripheral: 0x1c010ab00, identifier = BB8A7450-...A69B, name = Polar H7 DCB69F17, state = disconnected>

One of them should be your heart rate monitor, as long as you are wearing it and have a valid heart rate.

Scanning for Peripherals with Specific Services

Wouldn’t it be better if you could only scan for heart rate monitors, since that is the only kind of peripheral you are currently interested in? In Bluetooth-speak, you only want to scan for peripherals that provide the Heart Rate service. To do that, you’ll need the UUID for the Heart Rate service. Search for heart rate in the list of services on the Bluetooth services specification page and note the UUID for it; 0x180D.

From the UUID, you’ll create a CBUUID object and pass it to scanForPeripherals(withServices:), which actually takes an array. So, in this case, it will be an array with a single CBUUID object, since you’re only interested in the heart rate service.

Add the following to the top of the file, right below the import statements:

let heartRateServiceCBUUID = CBUUID(string: "0x180D")

Update the scanForPeripherals(withServices: nil) line to the following:

centralManager.scanForPeripherals(withServices: [heartRateServiceCBUUID])

Build and run, and you should now only see your heart rate sensor being discovered:

<CBPeripheral: 0x1c0117220, identifier = BB8A7450-...A69B, name = Polar H7 DCB69F17, state = disconnected>
<CBPeripheral: 0x1c0117190, identifier = BB8A7450-...A69B, name = Polar H7 DCB69F17, state = disconnected>

Next you’ll store a reference to the heart rate peripheral and then can stop scanning for further peripherals.

Add a heartRatePeripheral instance variable of type CBPeripheral at the top, right after the centralManager variable:

var heartRatePeripheral: CBPeripheral!

Once the peripheral is found, store a reference to it and stop scanning. In centralManager(_:didDiscover:advertisementData:rssi:), add the following after print(peripheral) :

heartRatePeripheral = peripheral
centralManager.stopScan()

Build and run; you should now see the peripheral printed just once.

<CBPeripheral: 0x1c010ccc0, identifier = BB8A7450-...A69B, name = Polar H7 DCB69F17, state = disconnected>

Connecting to a Peripheral

To obtain data from a peripheral you’ll need to connect to it. Right below centralManager.stopScan(), start typing centralManager.connect and you should see connect(peripheral: CBPeripheral, options: [String: Any]?) appear:

Select it, use heartRatePeripheral for the first parameter and delete the options: parameter so that you end up with the following:

centralManager.connect(heartRatePeripheral)

Great! Not only have you discovered your heart rate sensor, but you have connected to it as well! But how can you confirm that you are actually connected? There must be a delegate method for this with the word connect in it. Right after the centralManager(_:didDiscover:advertisementData:rssi:) delegate method, type connect and select centralManager(_:didConnect:):

Replace the code placeholder as follows:

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
  print("Connected!")
}

Build and run; you should see Connected! printed to the console confirming that you are indeed connected to it.

Connected!

Discovering a Peripheral’s Services

Now that you’re connected, the next step is to discover the services of the peripheral. Yes, even though you specifically requested a peripheral with the heart rate service and you know that this particular peripheral supports this, you still need to discover the service to use it.

After connecting, call discoverServices(nil) on the peripheral to discover its services:

heartRatePeripheral.discoverServices(nil)

You can pass in UUIDs for the services here, but for now you’ll discover all available services to see what else the heart rate monitor can do.

Build and run and note the two API MISUSE messages in the console:

API MISUSE: Discovering services for peripheral <CBPeripheral: 0x1c010f6f0, ...> while delegate is either nil or does not implement peripheral:didDiscoverServices:
API MISUSE: <CBPeripheral: 0x1c010f6f0, ...> can only accept commands while in the connected state

The second message indicates that the peripheral can only accept commands while it’s connected. The issue is that you initiated a connection to the peripheral, but didn’t wait for it to finish connecting before you called discoverServices(_:)!

Move heartRatePeripheral.discoverServices(nil) into centralManager(_:didConnect:) right below print("Connected!"). centralManager(_:didConnect:) should now look like this:

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
  print("Connected!")
  heartRatePeripheral.discoverServices(nil)
}

Build and run. Now you should only see the other API MISUSE message which is:

API MISUSE: Discovering services for peripheral <CBPeripheral: ...> while delegate is either nil or does not implement peripheral:didDiscoverServices:

The Core Bluetooth framework is indicating that you’ve asked to discover services, but you haven’t implemented the peripheral(_:didDiscoverServices:) delegate method.

The name of the method tells you that this is a delegate method for the peripheral, so you’ll need to conform to CBPeripheralDelegate to implement it.

Add the following extension to the end of the file:

extension HRMViewController: CBPeripheralDelegate {

}

Xcode doesn’t offer to add method stubs for this since there are no required delegate methods.

Within the extension, type discover and select peripheral(_:didDiscoverServices:):

Note that this method doesn’t provide you a list of discovered services, only that one or more services has been discovered by the peripheral. This is because the peripheral object has a property which gives you a list of services. Add the following code to the newly added method:

guard let services = peripheral.services else { return }

for service in services {
  print(service)
}

Build and run, and check the console. You won’t see anything printed and, in fact, you’ll still see the API MISUSE method. Can you guess why?

It’s because you haven’t yet pointed heartRatePeripheral at its delegate. Add the following after heartRatePeripheral = peripheral in centralManager(_:didDiscover:advertisementData:rssi:):

heartRatePeripheral.delegate = self

Build and run, and you’ll see the peripheral’s services printed to the console:

<CBService: 0x1c046f280, isPrimary = YES, UUID = Heart Rate>
<CBService: 0x1c046f5c0, isPrimary = YES, UUID = Device Information>
<CBService: 0x1c046f600, isPrimary = YES, UUID = Battery>
<CBService: 0x1c046f680, isPrimary = YES, UUID = 6217FF4B-FB31-1140-AD5A-A45545D7ECF3>

To get just the services you’re interested in, you can pass the CBUUIDs of those services into discoverServices(_:). Since you only need the Heart Rate service, update the discoverServices(nil) call in centralManager(_:didConnect:) as follows:

heartRatePeripheral.discoverServices([heartRateServiceCBUUID])

Build and run, and you should only see the Heart Rate service printed to the console.

<CBService: 0x1c046f280, isPrimary = YES, UUID = Heart Rate>

Discovering a Service’s Characteristics

The heart rate measurement is a characteristic of the heart rate service. Add the following statement right below the print(service) line in peripheral(_:didDiscoverServices:):

print(service.characteristics ?? "characteristics are nil")

Build and run to see what is printed to the console:

characteristics are nil

To obtain the characteristics of a service, you’ll need to explicitly request the discovery of the service’s characteristics:

Replace the print statement you just added with the following:

peripheral.discoverCharacteristics(nil, for: service)

Build and run, and check the console for some API MISUSE guidance on what should be done next:

API MISUSE: Discovering characteristics on peripheral <CBPeripheral: 0x1c0119110, ...> while delegate is either nil or does not implement peripheral:didDiscoverCharacteristicsForService:error:

You need to implement peripheral(_:didDiscoverCharacteristicsFor:error:). Add the following after peripheral(_:didDiscoverServices:) to print out the characteristic objects:

func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService,
                error: Error?) {
  guard let characteristics = service.characteristics else { return }

  for characteristic in characteristics {
    print(characteristic)
  }
}

Build and run. You should see the following printed to the console:

<CBCharacteristic: 0x1c00b0920, UUID = 2A37, properties = 0x10, value = (null), notifying = NO>
<CBCharacteristic: 0x1c00af300, UUID = 2A38, properties = 0x2, value = (null), notifying = NO>

This shows you that the heart rate service has two characteristics. If you are using a sensor other than the Polar H7, you may see additional characteristics. One with UUID 2A37, and the other with 2A38. Which one of these is the heart rate measurement characteristic? You can find out by searching for both numbers in the characteristics section of the Bluetooth specification.

On the Bluetooth specification page, you’ll see that 2A37 represents Heart Rate Measurement and 2A38 represents Body Sensor Location.

Add constants for these at the top of the file, below the line for heartRateServiceCBUUID. Adding the 0x prefix to the UUID is optional:

let heartRateMeasurementCharacteristicCBUUID = CBUUID(string: "2A37")
let bodySensorLocationCharacteristicCBUUID = CBUUID(string: "2A38")

Each characteristic has a property called properties of type CBCharacteristicProperties and is an OptionSet. You can view the different types of properties in the documentation for CBCharacteristicProperties, but here you’ll only focus on two: .read and .notify. You’ll need to obtain each characteristic’s value in a different manner.

Checking a Characteristic’s Properties

And the following code in peripheral(_:didDiscoverCharacteristicsFor:error:) after the print(characteristic) to see the characteristic’s properties:

if characteristic.properties.contains(.read) {
  print("\(characteristic.uuid): properties contains .read")
}
if characteristic.properties.contains(.notify) {
  print("\(characteristic.uuid): properties contains .notify")
}

Build and run. In the console you’ll see:

2A37: properties contains .notify
2A38: properties contains .read

The 2A37 characteristic — the heart rate measurement — will notify you when its value updates, so you’ll need to subscribe to receive updates from it. The 2A38 characteristic — the body sensor location — lets you read from it directly…although not quite that directly. You’ll see what I mean in the next section.

Obtaining the Body Sensor Location

Since getting the body sensor location is easier than getting the heart rate, you’ll do that first.

In the code you just added, after print("\(characteristic.uuid): properties contains .read"), add the following:

peripheral.readValue(for: characteristic)

So where is the value read to? Build and run for some further guidance from the Xcode console:

API MISUSE: Reading characteristic value for peripheral <CBPeripheral: 0x1c410b760, ...> while delegate is either nil or does not implement peripheral:didUpdateValueForCharacteristic:error:

The Core Bluetooth framework is telling you that you’ve asked to read a characteristic’s value, but haven’t implemented peripheral(_:didUpdateValueFor:error:). At first glance, this seems like a method that you’d need to implement only for characteristics that would notify you of an update, such as the heart rate. However, you also need to implement it for values that you read. The read operation is asynchronous: You request a read, and are then notified when the value has been read.

Add the method to the CBPeripheralDelegate extension:

func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,
                error: Error?) {
  switch characteristic.uuid {
    case bodySensorLocationCharacteristicCBUUID:
      print(characteristic.value ?? "no value")
    default:
      print("Unhandled Characteristic UUID: \(characteristic.uuid)")
  }
}

Build and run; you should see a “1 bytes” message printed to the console, which is the type of message you’d see when you print a Data object directly.

Interpreting the Binary Data of a Characteristic’s Value

To understand how to interpret the data from a characteristic, you have to refer to the Bluetooth specification for the characteristic. Click on the Body Sensor Location link on the Bluetooth characteristics page which will take you to the following page:

The specification shows you that Body Sensor Location is represented by an 8-bit value, so there are 255 possibilities, and only 0 – 6 are used at present. Based on the specification, add the following helper method to the end of the CBPeripheralDelegate extension:

private func bodyLocation(from characteristic: CBCharacteristic) -> String {
  guard let characteristicData = characteristic.value,
    let byte = characteristicData.first else { return "Error" }

  switch byte {
    case 0: return "Other"
    case 1: return "Chest"
    case 2: return "Wrist"
    case 3: return "Finger"
    case 4: return "Hand"
    case 5: return "Ear Lobe"
    case 6: return "Foot"
    default:
      return "Reserved for future use"
  }
}

Since the specification indicates the data consists of a single byte, you can call first on a Data object to get its first byte.

Replace peripheral(_:didUpdateValueFor:error:) with the following:

func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,
                error: Error?) {
  switch characteristic.uuid {
    case bodySensorLocationCharacteristicCBUUID:
      let bodySensorLocation = bodyLocation(from: characteristic)
      bodySensorLocationLabel.text = bodySensorLocation
    default:
      print("Unhandled Characteristic UUID: \(characteristic.uuid)")
  }
}

This uses your new helper function to update the label on the UI. Build and run, and you’ll see the body sensor location displayed:

Is there anywhere else I could have worn it?

Obtaining the Heart Rate Measurement

Finally, the moment you’ve been waiting for!

The heart rate measurement characteristic’s properties contained .notify, so you would need to subscribe to receive updates from it. The method you’ll need to call looks a bit weird: it’s setNotifyValue(_:for:).

Add the following to peripheral(_:didDiscoverCharacteristicsFor:error:) after print("\(characteristic.uuid): properties contains .notify"):

peripheral.setNotifyValue(true, for: characteristic)

Build and run, and you’ll see a number of “Unhandled Characteristic UUID: 2A37” messages printed out:

Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37

Congratulations! Within that characteristic’s value is your heart rate. The specification for the heart rate measurement is a bit more complex than that for the body sensor location. Take a look: heart rate measurement characteristic:

The first byte contains a number of flags, and the first bit within the first byte indicates if the heart rate measurement is an 8-bit value or a 16-bit value. If the first bit is a 0 then the heart rate value format is UINT8, i.e. an 8-bit number, and if the first byte is set to 1, the heart rate value format is UINT16, i.e. a 16-bit number.

The reason for this is that in most cases, your heart rate hopefully won’t go above 255 beats per minute, which can be represented in 8 bits. In the exceptional case that your heart rate does go over 255 bpm, then you’d need an additional byte to represent the heart rate. Although you’d then be covered for up to 65,535 bpm!

So now you can determine if the heart rate is represented by one or two bytes. The first byte is reserved for various flags, so the heart rate will be found in either the second byte or the second and third bytes. You can tell that the flags are contained in one byte since the Format column shows 8bit for it.

Note that the very last column, with the title Requires, shows C1 when the value of the bit is 0, and a C2 when the value of the bit is 1.

Scroll down to the C1 and C2 fields, which you’ll see immediately after the specification for the first byte:

Add the following helper method to the end of the CBPeripheralDelegate extension to obtain the heart rate value from the characteristic:

private func heartRate(from characteristic: CBCharacteristic) -> Int {
  guard let characteristicData = characteristic.value else { return -1 }
  let byteArray = [UInt8](characteristicData)

  let firstBitValue = byteArray[0] & 0x01
  if firstBitValue == 0 {
    // Heart Rate Value Format is in the 2nd byte
    return Int(byteArray[1])
  } else {
    // Heart Rate Value Format is in the 2nd and 3rd bytes
    return (Int(byteArray[1]) << 8) + Int(byteArray[2])
  }
}

From characteristic.value, which is an object of type Data, you create an array of bytes. Depending on the value of the first bit in the first byte, you either look at the second byte, i.e. byteArray[1], or you determine what the value would be by combining the second and third bytes. The second byte is shifted by 8 bits, which is equivalent to multiplying by 256. So the value in this case is (second byte value * 256) + (third byte value).

Finally, add another case statement above the default case in peripheral(_:didUpdateValueFor:error:) to read the heart rate from the characteristic.

case heartRateMeasurementCharacteristicCBUUID:
  let bpm = heartRate(from: characteristic)
  onHeartRateReceived(bpm)

onHeartRateReceived(_:) updates the UI with your heart rate.

Build and run your app, and you should finally see your heart rate appear. Try some light exercise and watch your heart rate rise!

Get that heart rate up!

Where to Go From Here?

Here is the completed final project with all of the code from the above tutorial.

In this tutorial, you learned about the Core Bluetooth framework and how you can use it to connect to and obtain data from Bluetooth devices.

You also may want to take a look at the Bluetooth Best Practices section of Apple's Energy Efficiency Guide for iOS Apps.

Want to learn about iBeacons? If so check out our iBeacon Tutorial with iOS and Swift tutorial.

If you have any questions or comments, please join the discussion below!

Team

Each tutorial at www.raywenderlich.com is created by a team of dedicated developers so that it meets our high quality standards. The team members who worked on this tutorial are:

Jawwad Ahmad

Jawwad currently works as an iOS Developer at Yelp. He enjoys teaching and was the original founder of the of the NYC iOS Study Group. He's worked for companies as large as The New York Times, and as small as GateGuru, a 6 person startup.

LinkedIn | GitHub | StackOverflow

Other Items of Interest

Save time.
Learn more with our video courses.

raywenderlich.com Weekly

Sign up to receive the latest tutorials from raywenderlich.com each week, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

PragmaConf 2016 Come check out Alt U

Our Books

Our Team

Video Team

... 27 total!

iOS Team

... 83 total!

Android Team

... 43 total!

Unity Team

... 16 total!

Articles Team

... 4 total!

Resident Authors Team

... 31 total!

Podcast Team

... 8 total!

Recruitment Team

... 8 total!

Illustration Team

... 4 total!