-
Notifications
You must be signed in to change notification settings - Fork 0
Learn ArkKit
When creating a game with Ark, it is helpful to be familiarized with three architectural patterns:
- Entity Component System (ECS)
- Flux
- 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.
To illustrate how to define a game with Ark, we use a simple vending machine game as shown below:
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 aVendingMachineEntity
. - If you want to name your entity, we encourage you to assign a
NameComponent
instead.
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
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.
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.
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")
}
)
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
}
}
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()
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)
}
}
To make entities in our game viewable, we attach RenderableComponent
s, 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
])
})
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 aframeWidth
and aframeHeight
to define the blueprint’scanvasFrame
. 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 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())
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
.
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
)
}
If no camera is defined, Ark creates a singular camera under the hood that views the whole canvas onto the screen display.
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)
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()
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)
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()
}
Animation instances can be marked for destroyal via animationInstance.markForDestroyal()
.
The playing and stopping of sounds is done in an event-driven way and can be specified via rules in ArkBlueprint.
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 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.
// 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.
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
.
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.
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
}
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)
])
To destroy an entity with a physics body, you can mark it to be destroyed via setting PhysicsComponent.toBeRemoved
to true
To configure multiplayer across devices, Ark supports a P2P network protocol.
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.
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.
}