How functional programming helps me sleep at night
Context
Problem
False Starts
Solution
Reflection
Lots of voxels
Lots of creatures/entities
Lovers of purity prepare your buckets
while(running) {
update(world, 0.05)
render(world)
}
def update(world:World, delta:Double) {
for(entity <- world.entities) {
entity.update(world, delta)
}
}
def render(world:World) {
for(entity <- world.entities) {
draw(entity)
}
}
class Entity {
def update(world:World, delta:Double) : Unit = {
// here be dragons
}
}
Launch missiles?
Probably not.
IO?
Probably not.
Unrestricted mutation of the game world?
Yeah :(
class World(val size:Vec2i) {
val blocks = new Array[Byte](size.product)
val entities = new mutable.HashMap[Int, Entity]()
class Entity(val position:Vec2i) {
val facing = Vec2i(1, 0)
var health = 100
var weaponDrawn = false
var attacking = false
class ServerWorld(val size:Vec2i) extends World {
val blocks = new Array[Byte](size.product)
val entities = new mutable.HashMap[Int, Entity]()
val blockChanges = new mutable.Queue[BlockChange]()
def setBlockAt(x:Int, y:Int, blockId:Byte) {
blocks(blockLocation(x, y)) = blockId
blockChanges.enqueue(BlockChange(x, y, blockId))
}
Bleh
SinglePlayerWorld / MultiplayerServerWorld / MultiplayerClientWorld ...
If entities can change anything in the world ...
val updatedWorld = entities.foldLeft(world) { (w, entity) =>
updateEntity(entity, w) // <- what the hell
}
def updateEntity(entity:Entity, world:World) : World
Unrestricted and awkward modification through copying
Reconstructing delta not solved
The updateEntity function is horrid
Disclaimer: I lack the formal qualifications to present the next slide
Too big to copy, let's orchestrate mutation
sealed trait WorldMonad[S] { // state thread s
private def blocks : Array[Byte]
def getBlockId(x:Int, y:Int) : ST[S, Byte]
def setBlockId(x:Int, y:Int, b:Byte) : ST[S, WorldMonad[S]]
def getEntity(entityId:Int) : ST[S, Entity]
}
// ST[S, A] is a "state transformer" roughly: S => (S, A)
// transforms state indexed by type S, delivers type A
object WorldMonad {
def apply[S](blocks:Array[Byte]) = new WorldMonad[S] {
val blocks = blocks
} // called once at the start of game to construct the world
}
def updateEntity[S](entity:Entity) : WorldMonad[S] => WorldMonad[S] // ???
... or maybe functional programming can't magically protect you from using poor abstractions and writing bad code
QuakeCon 2013 (heavily paraphrased)
"Run all your entities independently in a functionally pure way, pass in a static copy of the world (from last frame) and themselves, and they return a new version of themselves at the end"
def updateEntity(entity:Entity, world:World) : Entity
But Mr. Carmack? How are the entities going to affect change outside of themselves?
"You create events that get communicated to the target entities"
In their simplest form: Entity => Entity
def takeDamage(damage:Int)(e:Entity) : Entity = {
e.copy(health = clamp(e.health - damage, 0, 100))
} // partially apply with a damage value to get an "event"
Events are then routed to their target entities which evaluate them on next frame
case class TakeDamage(damage:Int, fromEntityId:Int, toEntityId:Int) extends Event {
// immutable version
def applyTo(entity:Entity) : (Entity, Seq[Event]) = {
(entity.copy(health = clamp(entity.health - damage, 0, 100)), Seq.empty)
}
// mutable version
def applyTo(entity:Entity) : Seq[Event] {
entity.health = clamp(entity.health - damage, 0, 100)
Seq.empty
}
def updateEntity(entity:ReadonlyEntity, world:ReadonlyWorld) :
(Seq[Event], Seq[StateTransition])
Goblin A hits Goblin B
Game logic is pure functions that emit events
def updateEntity(entity:ReadonlyEntity, world:ReadonlyWorld) :
(Seq[Event], Seq[StateTransition]) // 90%
// our game loop becomes
val eventsToRoute = world.entities.values.flatMap { entity =>
val replyEvents = entity.events.flatMap { ev =>
ev.applyTo(entity) // take damage from others etc.
}
val (externalEvents, stateTransitions) = updateEntity(
entity.asInstanceOf[ReadonlyEntity],
worldFromLastFrame
)
for(t <- stateTransitions) {
t.applyTo(entity) // change entity position etc.
}
replyEvents ++ externalEvents
}
// route events and repeat
Mutable
// send events out between frames
for(event <- eventsToRoute) {
world.entities(event.toEntityId).events += event
}
Immutable
// group the events by toEntityId before the frame
val eventsById = eventsFromLastFrame.groupBy(_.toEntityId).withDefault(Seq.empty)
// Map[Int, Seq[Event]]
val eventsToRoute = world.entities.values.flatMap { entity =>
val replyEvents = eventsById(entity.id).flatMap { ev =>
ev.applyTo(entity)
}
// the rest of the update code
val eventsById = eventsFromLastFrame.groupBy(_.toEntityId).withDefault(Seq.empty)
// Map[Int, Seq[Event]]
val eventsToRoute = world.entities.flatMap { entity =>
val replyEvents = eventsById(entity.id).flatMap { ev =>
ev.applyTo(entity) // take damage from others etc.
}
val (externalEvents, stateTransitions) = updateEntity(
entity.asInstanceOf[ReadonlyEntity],
worldFromLastFrame
)
for(t <- stateTransitions) {
t.applyTo(entity) // change entity position etc.
}
replyEvents ++ externalEvents
}
case class PickupItem(toItemId:Int, fromEntityId:Int) extends Event {
def applyTo(entity:Entity) : Seq[Event] = {
if(entity.alive) {
entity.alive = false
Seq(SuccessfulPickup(entity.itemType, fromEntityId))
} else {
Seq(FailedPickup(entity.itemType, fromEntityId))
}
}
}
case class SuccessfulPickup(itemType:Int, toEntityId:Int) extends Event {
def applyTo(entity:Entity) : Seq[Event] {
entity.itemInHands = Some(itemType)
Seq.empty
}
}
case class FailedPickup(itemType:Int, toEntityId:Int) extends Event
This is all great, but I don't recall seeking out purity/correctness or even immutability ...
Since mutation is entity local, our game loop can become
val eventsToRoute = world.entities.par.flatMap { entity =>
val replyEvents = eventsById(entity.id).flatMap { ev =>
ev.applyTo(entity) // take damage from others etc.
}
val (externalEvents, stateTransitions) = updateEntity(
entity.asInstanceOf[ReadonlyEntity],
worldFromLastFrame
)
// rest of update code
(We added the .par)
Performance Shmerformance
def updateClientWorld(playerId:Int, world:World, server:MultiPlayerServer) {
// apply state updates from server
for((id, events) <- server.entityEvents.groupBy(_.toEntityId)) {
val entity = world.entityFor(id)
for(event <- events) {
event.applyTo(entity)
}
}
// locally simulate our player entity
val playerEntity = world.getEntity(playerId)
val replyEvents = server.playerEvents.flatMap { ev =>
ev.applyTo(playerEntity)
}
val (externalEvents, stateTransitions) = updateEntity(playerEntity, world)
for(t <- stateTransitions) {
t.applyTo(entity) // mutation
}
server.sendEvents(stateTransitions, externalEvents ++ replyEvents)
// all events must go to the server
}
Multiplayer/Singleplayer is only a routing change away
(with some custom events too)
Multiplayer clients can never be certain about the success of actions until sent to the server, serialized and replied to.
This is the speed of light rearing it's ugly head again.
In order to support multiplayer later, you need to act like multiplayer now, handle failure & conflict.
Why didn't events seem obvious from the start?
Sometimes your abstraction can be horribly, horribly wrong.
This might not be obvious until a better solution is shown to you (Dunning–Kruger applied to code?)
Events (or anything) aren't going to fit every model
In my case an FP approach + events was a natural fit
Thanks for listening
Slides & example code are on michaelshaw.io