Skip to content

Learn ArkKit

Didymus Ne edited this page Apr 21, 2024 · 2 revisions

Dev Manual (For Game Devs using Ark)

Introduction

When creating a game with Ark, it is helpful to be familiarized with three architectural patterns:

  1. Entity Component System (ECS)
  2. Flux
  3. MVVM

Underlying, Ark implements its version of a marriage between Flux and ECS for domain logic. All app logic in Ark uses the ECS paradigm and Flux architecture. Ark uses the MVVM architecture (by wrapping Flux + ECS as its model) to render the game to the View.

Initialising game state

To illustrate how to define a game with Ark, we use a simple vending machine game as shown below:

660px-Turnstile_state_machine_colored.svg

In this game, the player must push some amount of coins into a vending machine until the vending machine state turns from locked to unlocked.

// First, we define the LockableComponent, which holds the data on whether the entity that owns this component isLocked

struct LockableComponent: Component {
    let isLocked: Bool
}

var blueprint = ArkBlueprint(frameWidth: 820, frameHeight: 1_180) // tells the blueprint what is the frame coordinate bounds
.setup { context in
    let ecsContext = context.ecs
    ecsContext.createEntity(with: [LockableComponent(isLocked: true)] // sets the entity to be locked
})
  • The entity holding the LockableComponent is essentially the Vending Machine. However, as in ECS, we do not need to define a VendingMachineEntity.
  • If you want to name your entity, we encourage you to assign a NameComponent instead.

Updating game state

States of games in Ark are driven by triggers. Whenever a trigger happens, actions are performed in response.

A Rule represents an Action that is executed once an RuleTrigger occurs.

Ark supports two types of Triggers:

  • EventType Trigger
  • GameLoopUpdate Trigger

Using EventType Triggers

In our coin machine example, we have two possible events that occur: PushEvent and CoinEvent.

// First, define the events
struct CoinEventData: ArkEventData {
    var coinAmount: Double
}
struct PushEventData: ArkEventData {
    var coinType: CoinTypeEnum // 5c, 20c, 50c, $1 coins
}
struct CoinEvent: ArkEvent {
    static var id = UUID()
    var eventData: ArkEventData?
    var timestamp = Date()
    var priority: Int?

    init(eventData: ArkEventData? = nil, priority: Int? = nil) {
        self.eventData = eventData
        self.priority = priority
    }
}
struct PushEvent: ArkEvent {
    static var id = UUID()
    var eventData: ArkEventData?
    var timestamp = Date()
    var priority: Int?

    init(eventData: ArkEventData? = nil, priority: Int? = nil) {
        self.eventData = eventData
        self.priority = priority
    }
}

// Set up rules
blueprint
.on(PushEvent.self) { pushEvent, context in
    let eventContext = context.events
    var coinAmount = translateCoinTypeToAmount(pushEvent.eventData.coinType)
    var coinEvent = CoinEvent(eventData: CoinEventData(coinAmount: coinAmount))
    eventContext.emit(coinEvent)
}
.on(CoinEvent.self) { coinEvent, actionContext in
    let ecsContext = context.ecs
    if hasEnoughCoinValue(coinEvent.eventData.coinAmount) {
        guard let lockableEntity = ecsContext.getEntities(with: [LockableComponent.self]).first,
              let lockableComponent = ecsContext.getComponent(ofType: LockableComponent.self, from: lockableEntity)
 else {
            return
}
        ecsContext.upsertComponent(LockableComponent(isLocked: false), to: lockableEntity)
})

Through these rules, the game developer is defining the events that move the state of the game from “Locked” to “Unlocked” as shown in the diagram.

Specifying the Order of Execution

For a certain event that happens, multiple actions can be invoked. To impose an order among these actions, we can use the chain parameter.

blueprint
.on(PushEvent.self, chain: { pushEvent, context in 
    // first Action to execute on PushEvent
}, { pushEvent, context in
    // second Action to execute on Push Event
   } // ... etc
)

If an order is not defined via chain, the order of execution among the actions defined is not guarenteed to be deterministic, and not guarenteed to be on insertion order.

Specifying Event-based conditional actions

Certain actions can be conditional, whereby when the event occurs, certain predicate(s) needs to be fulfilled for the action to execute. Ark provides a declarative way to achieve this.

blueprint.on(
    TankMoveEvent.self,
    // the evaluation of these predicates uses logical AND
    executeIf: { _ in false }, { _ in true }, // etc
    then: { _, _ in
           // define action
           print("will not execute")
          }
)

Using GameLoopUpdate Triggers

Suppose we want to do a health check on our vending machine each tick to ensure that the vending machine is available.

// ... define the VendingMachineHealthComponent

blueprint.forEachTick { deltaTime, context in
    let ecs = context.ecs
    guard let vendingMachineEntity = ecs.getEntities(with: [VendingMachineHealthComponent.self]).first,
          let healthComponent = ecs.getComponent(ofType: VendingMachineHealthComponent.self, from: vendingMachineEntity) else {
        return
    }
    if healthComponent.health < 0 {
        // perform activities such as emitting DepletedHealth event
    }                       
}

Starting the game

To start the game, we create an Ark instance with the completed blueprint.

Ark initializes the game state using the setup functions and rules defined in the blueprint.

// wherever the rootView is to be rendered e.g. in UIKit, we can inject this from the SceneDelegate
// NOTE that you should keep a strong reference to this Ark object somewhere so that Ark is not dereferenced
let ark = Ark(rootView: ViewController(), blueprint: blueprint)

We run the game by doing the following:

ark.start()

Displaying the game

Ark renders the game into the rootView argument. Ark is framework-agnostic - it supports both UIKit or SwiftUI views. Regardless, the rootView has to conform to the protocol AbstractRootView<T>.

Under the hood, Ark renders the game into the provided view argument by mounting its own internal ArkView onto the rootView provided.

import UIKit

class RootViewController: UINavigationController {
}

extension RootViewController: AbstractRootView {
    var abstractView: UIView {
        self.view
    }

    var size: CGSize {
        view.frame.size
    }

    func pushView(_ view: any AbstractView<UIView>, animated: Bool) {
        guard let castedViewController = view as? UIViewController else {
            return
        }
        self.pushViewController(castedViewController, animated: animated)
    }
}

Representing viewable objects

To make entities in our game viewable, we attach RenderableComponents, which represent primitives like:

  • Shapes
    • CircleRenderableComponent
    • RectRenderableComponent
    • PolygonRenderableComponent
  • Bitmap
    • BitmapImageRenderableComponent
  • Inputs
    • JoystickRenderableComponent
    • ButtonRenderableComponent
  • Containers
    • CameraContainerRenderableComponent

We represent the vending machine entity with an image with the path “vending-machine” like so:

// 1. Create vending machine bitmap
let vendingMachineBitmap = BitmapImageRenderableComponent(
    imageResourcePath: "vending-machine", // string path to image
    width: 80,
    height: 100
)
.center(CGPoint(x: 500, y: 500)) // the coordinates are relative to the frame defined in the blueprint
.rotation(0.0)
.zPosition(1.0)
.scaleAspectFill()


let blueprint = ArkBlueprint(frameWidth: 820, frameHeight: 1_180)
.setup { context in
    let ecs = context.ecs
        
    // 2. Create vending machine with bitmap
    ecs.createEntity(with: [
        LockableComponent(isLocked: true),
        vendingMachineBitmap
    ])
})

Placing/positioning viewable objects

Objects are placed either on the screen or the canvas.

The screen represents the entire screen and is where game controls and UI elements are placed.

The canvas represents a fixed-size container scaled and centered within the screen. This is usually the game world.

When using ArkBlueprint, we define a frameWidth and a frameHeight to define the blueprint’s canvasFrame. This is the size of the canvas.

RenderableComponent()
.layer(.canvas)
// x, y are relative to canvas view
.position(x: x, y: y)

RenderableComponent()
.layer(.screen)
// x, y are relative to screen view
.position(x: x, y: y)

Custom components

Custom components can be created by conforming to the RenderableComponent protocol.

The RenderableBuilder will need to be extended to provide the mapping of the RenderableComponent into a Renderable so that the component can be rendered.

Currently, Ark provides a default library ark-uikit-lib, which its internal implementation of the UIKit view components and the ArkUIKitRenderableBuilder: RenderableBuilder that maps each RenderableComponent to its corresponding UIKit view component.

To pass in your custom library, inject the custom RenderableBuilder into Ark.

let ark = Ark(rootView: YourView(),
              blueprint: yourBlueprint,
              // this is an optional parameter
              // if unprovided, renderables are built with the default `ark-uikit-lib` renderableBuilder.
              canvasRenderableBuilder: YourCustomRenderableBuilder())

Using Camera

Ark provides Camera through the ArkCameraKit. A camera is defined as the mask over which the canvas is shown fromcanvas onto the screen, and is able to track entities based on their positions in the canvas.

The Camera is configurable by the position and by zoom. Based on the camera's tracking position, zoom, and its associated screenSize and screenPosition, the camera takes the associated canvas view and letterboxes it into the camera's screenSize.

Attaching a Camera to an Entity

We attach the camera to any entity and track the entity's position in the canvas world by inserting the PlacedCameraComponent to the entity.

Under the hood, Ark runs a ArkCameraSystem that replaces the position associated with the PlacedCameraComponent to the value of the PositionComponent attached to the entity for each game loop tick.

blueprint.setup { context in
    let ecs = context.ecs
    let screenWidth = context.display.screenSize.screenWidth
    let screenHeight = context.display.screenSize.screenHeight
    let canvasWidth = context.display.canvasSize.canvasWidth
    let canvasHeight = context.display.canvasSize.canvasHeight
    
    // ... define a trackable entity, `trackableEntity`
    // ... initialise the trackable entitiy with a position, `trackableEntitiyPosition`

    ecs.upsertComponent(
        PlacedCameraComponent(
            camera: Camera(
                canvasPosition: trackableEntityPosition, // initialises the camera to be at the position of the entity in the canvas
                zoomWidth: 1, // zoom params are optional, default = 1.0
                zoomHeight: 10.0),
            
            // defines the position of the camera to view on the screen
            screenPosition: CGPoint(
                x: screenWidth / 2, 
                y: screenHeight / 2
            ),
            size: CGSize(
                width: screenWidth / 3,
                height: screenHeight
            )
        ),
        to: trackableEntity
    )
}

With No Cameras

If no camera is defined, Ark creates a singular camera under the hood that views the whole canvas onto the screen display.

Animating values

Keyframe-based animations can be created easily using ArkAnimationKit.

An animation is defined as a value transition over time.

protocol Animation<T> {
    associatedtype T: Equatable
    
    var keyframes: [AnimationKeyframe<T>] { get }
    var duration: TimeInterval { get }
}

struct AnimationKeyframe<T: Equatable>: Equatable {
    let value: T
    let offset: TimeInterval
    let duration: TimeInterval
}

Animations can be defined using the builder exposed by ArkAnimationKit. Since animations are generic, animations of different value types can created.

let spriteAnimation = ArkAnimation()
    .keyframe("Sprite_Effects_Explosion_001", duration: 0.5)
    .keyframe("Sprite_Effects_Explosion_002", duration: 0.5)
    .keyframe("Sprite_Effects_Explosion_003", duration: 0.5)

let fadeInOpacityAnimation = ArkAnimation()
    .keyframe(0, duration: 0)
    .keyframe(1, duration: 1)

Instantiating animations

Animations have to be instantiated to run them.

An AnimationInstance is an Animation whose current state is determined by its elapsedDelta.

protocol AnimationInstance<T>: AnyObject where T: Equatable {
    associatedtype T
    
    var animation: ArkAnimation<T> { get }
    var elapsedDelta: TimeInterval { get set }
    var updateDelegate: UpdateDelegate<T>? { get }
    var completeDelegate: CompleteDelegate<T>? { get }
    var status: AnimationStatus { get }
    var shouldDestroy: Bool { get set }
    var currentFrame: AnimationKeyframe<T> { get }
}

ArkAnimationInstances can be instantiated using the following:

let explosionAnimationInstance = ArkAnimation()
    .keyframe("Sprite_Effects_Explosion_001", duration: 0.5)
    .keyframe("Sprite_Effects_Explosion_002", duration: 0.5)
    .keyframe("Sprite_Effects_Explosion_003", duration: 0.5)
    .toInstance()

Updating animations

Animation instances are played by updating their elapsedDelta. ArkAnimationKit provides a convenience component which allows animations to be attached to any entity.

struct ArkAnimationsComponent: Component {
    var animations: [any AnimationInstance]
}

To add animations to an entity, simply add animations to ArkAnimationsComponent to automatically update their elapsedDelta.

The built-in AnimationSystem updates all animation instances in all ArkAnimationsComponent by adding it into the ECS.

let entity = // define entity
let existingAnimationsComponent = ecs.getComponent(ofType: ArkAnimationsComponent.self, for: entity)
var animationsComponent = existingAnimationsComponent ?? ArkAnimationsComponent()

// Add animation
animationsComponent.animations.append(explosionAnimationInstance)

// Update entity
ecs.upsertComponent(animationsComponent, to: entity)

Handling animation changes

Keyframe changes are handled via attaching onUpdate and onComplete callback functions to animation instances. onUpdate is called for every frame change, including the last. onComplete is called for the last frame

let animationInstance = ArkAnimation()
    .keyframe(/* ... */)
    .keyframe(/* ... */)
    .toInstance()
    .onUpdate { instance in
       let currentFrame = instance.currentFrame
        // Do stuff with current frame
    }
    .onComplete { instance in
       let currentFrame = instance.currentFrame
        // Do stuff with last frame
                 
         // Mark animation for destroyal
        instance.markForDestroyal()
    }

Destroying animations

Animation instances can be marked for destroyal via animationInstance.markForDestroyal().

Adding sounds

The playing and stopping of sounds is done in an event-driven way and can be specified via rules in ArkBlueprint.

Defining a sound collection for your game

ArkKit supports a typesafe way of defining your audio files.

Let's start by defining an enum which contains all the different sounds you will have in the game.

enum TankGameSounds: Int {
    case shoot, move
}

Next, we can define new sounds by implementing the Sound protocol.

struct TankShootSound: Sound {
    let filename = "shoot"
    let fileExtension = "mp3"
    let numberOfLoops = 0 // negative for infinite looping
}

Finally, we can declare a mapping between the enum and the sounds written.

let tankGameSoundMapping: [TankGameSounds: any Sound] = [
    .shoot: TankShootSound(),
    .move: TankMoveSound(),
]

To add your new sound collection, we can do so through ArkBlueprint.

// Typesafe blueprint sound definitions
ArkBlueprint<TankGameSounds>(frameWidth: 100, frameHeight: 100)
    .withAudio(tankGameSoundMappings)

Playing a sound

Playing of sounds is inherently event-driven (e.g. on game start, on tank shoot, etc.). Thus, to play a sound, we can define it through rules in ArkBlueprint.

ArkBlueprint<TankGameSounds>(frameWidth: 100, frameHeight: 100)
    .withAudio(tankGameSoundMappings)

    // Play TankShootEvent on EventA
    .on(TankShootEvent.self) { event, context in
        context.audio.play(.shoot) // autocompletion available
    }

Underneath the hood, ArkKit handles the playing of multiple instances of the same audio file concurrently.

Stopping a sound

// To stop a particular sound, a unique audioplayer UUID should be
// passed into the `play` method. This enables granular control over
// multiple instances of the same sound.

let uuid = UUID() // can be an EntityID passed through event.eventData too
ArkBlueprint<TankGameSounds>(frameWidth: 100, frameHeight: 100)
    .withAudio(tankGameSoundMappings)
    .on(TankMoveEvent.self) { event, context in
        context.audio.play(.move, audioPlayerId: uuid)
    }
    .on(TankStopEvent.self) { event, context in
        context.audio.stop(.move, audioPlayerId: uuid)
    }

If no playerId is passed into the .stop method, all instances of the given sound will be stopped.

Defining game physics

Ark comes with a 2D physics engine that can simulate physics interactions.

An entity can participate in the game's physics world by having a PhysicsComponent, PositionComponent and RotationComponent.

Physical properties available:

The PhysicsComponent in the ArkPhysicsKit provides a comprehensive set of properties to define the physics characteristics of entities within a game or simulation environment.

  • Shape: Determines the geometric form of the physics body (circle, rectangle)
  • Size: Specifies the dimensions for a rectangle shape.
  • Radius: Specifies the dimensions for a circular shape.
  • Mass: Indicates the mass of the entity.
  • Velocity: Represents the current velocity of the entity as a vector.
  • isDynamic: A boolean indicating if the entity is movable (true) or static (false)
  • affectedByGravity: A boolean to indicate if the entity is affected by gravity.
  • linearDamping: Damping of the linear velocity over time, simulates friction or air resistance.
  • angularDamping: Similar to linear damping but for rotation.
  • allowsRotation: Determines if the entity is allowed to rotate when subjected to forces.
  • friction: Affects the amount of sliding resistance when in contact with other entities.
  • restitution: Controls the bounciness of the entity upon collision with another body.
  • categoryBitMask: Defines the category of bodies this physics body belongs to
  • collisionBitMask: Specifies which categories of bodies this entity should collide with.
  • contactBitMask: Specifies which category of bodies this entity will report a contact with.
  • toBeRemoved: A boolean marking an entity to be removed in the simulation.

Setting up collision bitmasks

enum TankGamePhysicsCategory {
    static let none: UInt32 = 0
    static let tank: UInt32 = 0x1 << 0
    static let ball: UInt32 = 0x1 << 1
    static let water: UInt32 = 0x1 << 2
    static let wall: UInt32 = 0x1 << 3
    static let rock: UInt32 = 0x1 << 4
}

Creating a physics body

Example of an entity to be added into the physics simulation:

ecsContext.createEntity(with: [
    PositionComponent(position: CGPoint(x: 400, y: 1_000)),
    RotationComponent(angleInRadians: Double.pi),
    PhysicsComponent(shape: .rectangle, 
                     size: CGSize(width: 80, height: 100),
                     mass: 1.0,
                     velocity: .zero,
                     isDynamic: false,
                     affectedByGravity: false
                     linearDamping: 0.1,
                     angularDamping: 1.0,
                     friction: 0.0
                     allowsRotation: false, 
                     restitution: 0,
                     // The entity has the category of a tank (It can contain multiple categories to compose different set of contact handling).
                     categoryBitMask: TankGamePhysicsCategory.tank,
                     // The entity will simulate a collision with rock, wall or tank.
                     collisionBitMask: TankGamePhysicsCategory.rock | TankGamePhysicsCategory.wall | TankGamePhysicsCategory.tank,
                     // The entity will emit a contact event with a ball, tank wall or water.
                     contactTestBitMask: TankGamePhysicsCategory.ball | TankGamePhysicsCategory.tank | TankGamePhysicsCategory.wall | TankGamePhysicsCategory.water)
])

Destroying entities with physics bodies

To destroy an entity with a physics body, you can mark it to be destroyed via setting PhysicsComponent.toBeRemoved to true

Configure Multiplayer across Devices

To configure multiplayer across devices, Ark supports a P2P network protocol.

Configuring P2P Network

blueprint
.supportNetworkMultiPlayer(
    roomName: "Any-Room-Name-of-Your-Choice", numberOfPlayers: 2
)

Under the hood, the roomName parameter is used as part of the secret to decrypt the network messages. Hence, only devices holding the same roomName can communicate with each other.

Configuring Player-Specific setup

Ark automatically syncs the states across the devices. However, devs may not want to sync all states (e.g. controls of each player, etc).

blueprint
.setupPlayer { context in
    // set up defined here will only be executed
    // for one player device.
}
.setupPlayer { context in
    // chain multiple setupPlayers to set up
    // for other devices.
}