0

A Game Engine in the Elm Style! – Vsyncronicity

 1 year ago
source link: https://vsyncronicity.com/2020/03/01/a-game-engine-in-the-elm-style/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

A ‘Nu’ way to make games!

The Nu Game Engine was the world’s first practical, purely-functional game engine. And it has recently accomplished another first — allowing developers to use Elm-style architecture (AKA, model-view-update) to build their games in the cleanest, most understandable possible way!

This article will go over two examples often used by the Elm developer written in Nu. The first shows an tiny Elm-style UI in Nu, and the second a tiny little Mario-like example.

For a quick intro to Elm and the Elm-style, see here – https://guide.elm-lang.org/

9ed27-1jaorikj0hrgfukzr2ivdlg.png

Because it is simpler, let’s start by looking at Nu’s way of implementing the canonical Elm-style UI –

dbb8c-1itsasygpixrr-tm7tq9lsa.png

The full code is as follows (see it on Github) –

namespace Nelmish
open Prime
open Nu
open Nu.Declarative
// this is our Elm-style model type
type Model =
int
// this is our Elm-style message type
type Message =
| Decrement
| Increment
| Reset
// this is our Elm-style game dispatcher
type NelmishDispatcher () =
inherit GameDispatcher<Model, Message, unit> (0) // initial model value
// here we handle the Elm-style messages
override this.Message (model, message, _, _) =
match message with
| Decrement -> just (model - 1)
| Increment -> just (model + 1)
| Reset -> just 0
// here we describe the content of the game including its one screen, one group, three
// button entities, and one text control.
override this.Content (model, _) =
[Content.screen Simulants.Default.Screen.Name Vanilla []
[Content.group Simulants.Default.Group.Name []
[Content.button "Decrement"
[Entity.Text == "-"
Entity.Position == v3 -256.0f 64.0f 0.0f
Entity.ClickEvent ==> msg Decrement]
Content.button "Increment"
[Entity.Text == "+"
Entity.Position == v3 0.0f 64.0f 0.0f
Entity.ClickEvent ==> msg Increment]
Content.text "Counter"
[Entity.Text <== model --> scstring
Entity.Position == v3 -128.0f -32.0f 0.0f
Entity.Justification == Justified (JustifyCenter, JustifyMiddle)]
Content.entityIf<ButtonDispatcher> "Reset" (model --> notZero)
[Entity.Text == "Reset"
Entity.Position == v3 -128.0f -128.0f 0.0f
Entity.ClickEvent ==> msg Reset]]]]

This example code creates a Nu game program that shows a + button as well as a button that automatically change the numeric value of the counter button when clicked. Additionally, there is a reset button that will revert the counter to its original value (and only exists when the value has changed).

Let’s step through each part of the code, from the top –

// this is our Elm-style model type
type Model =
int

Here we have the Model type that users may customize to represent their simulant’s ongoing state. Here we use just an int to represent the counter value shown by the Counter label. If we were to write, say, a custom Text widget, the Model type would be a string instead of an int. You’re not limited to primitive types, however — you may make your model type as sophisticated as you see fit.

// this is our Elm-style message type
type Message =
| Decrement
| Increment
| Reset

This is the Message type that represents all possible changes that the Message function will handle (in Elm, the Message function is called Update, but we don’t call it Update in Nu because that function already exists as a lower-level function for simulant ticking).

// this is our Elm-style game dispatcher
type NelmishDispatcher () =
inherit GameDispatcher<Model, Message, unit> (0) // initial model value

This code does three things –

  1. It declares a containing scope for the Elm-style function overrides (seen coming up next) and packages them as a single plug-in for use by external programs such as Nu’s world editor, Gaia.
  2. It allows the user to specify the Elm-style model, message, and command types. Here we pass unit for the command type since this simulant doesn’t utilize commands.
  3. The base constructor function takes as a parameter the initial Model value (here, 0).

Let’s look at the next bit –

// here we handle the Elm-style messages
override this.Message (model, message, _, _) =
match message with
| Decrement -> just (model - 1)
| Increment -> just (model + 1)
| Reset -> just 0

This is the Message function itself. All it does is match each message it receives to an expression that changes the Model value in an appropriate way.

“But what is that just function?”

Good question! Strictly speaking, the Message functions return both a new Model as well as a list of commands for processing by the unused Command function. But since we’re not using commands here, I use the just function to automatically pair the changed model value with an empty command list. It’s just a little bit of syntactic sugar to keep things maximally readable!

// here we describe the content of the game including its one screen, one group, three
// button entities, and one text control.
override this.Content (model, _) =
[Content.screen Simulants.Default.Screen.Name Vanilla []
[Content.group Simulants.Default.Group.Name []
[Content.button "Decrement"
[Entity.Text == "-"
Entity.Position == v3 -256.0f 64.0f 0.0f
Entity.ClickEvent ==> msg Decrement]
Content.button "Increment"
[Entity.Text == "+"
Entity.Position == v3 0.0f 64.0f 0.0f
Entity.ClickEvent ==> msg Increment]
Content.text "Counter"
[Entity.Text <== model --> scstring
Entity.Position == v3 -128.0f -32.0f 0.0f
Entity.Justification == Justified (JustifyCenter, JustifyMiddle)]
Content.entityIf<ButtonDispatcher> "Reset" (model --> notZero)
[Entity.Text == "Reset"
Entity.Position == v3 -128.0f -128.0f 0.0f
Entity.ClickEvent ==> msg Reset]]]]

Here we have the Content function. The Content function is mostly equivalent to the View function in Elm. Here the Content function defines the game’s automatically-created (and destroyed as is needed) simulants. Studying this structure, we can see that we have a simulant structure like this –

47b07-13cqlh614k8zamu8m9aszow.png

The Content function declares that the above hierarchy is instantiated at run-time. Each Content clause can also define its respective simulant’s properties and event handlers in a declarative way.

[Content.button "Decrement"
[Entity.Text == "-"
Entity.Position == v2 -256.0f 64.0f
Entity.ClickEvent ==> msg Decrement]

Here we have the DecrementButton’s Text property defined as “-”, its Position translated up and to the left, and its ClickEvent producing the Decrement message that was handled above.

Content.button "Increment"
[Entity.Text == "+"
Entity.Position == v2 0.0f 64.0f
Entity.ClickEvent ==> msg Increment]

Here is the IncrementButton, which produces the Increment message.

Content.text "Counter"
[Entity.Text <== model --> scstring
Entity.Position == v2 -128.0f -32.0f
Entity.Justification == Justified (JustifyCenter, JustifyMiddle)]

Here we see how the CounterText derives its Text property by mapping the model with the scstring function. scstring is just a special string conversion function and in functional terms the operator maps the right-hand side function over the model. We also see it producing the Reset message off of its ClickEvent

Content.entityIf "Reset" (model --> isNonZero)
[Entity.Text == "Reset"
Entity.Position == v2 -128.0f -128.0f
Entity.ClickEvent ==> msg Reset]]]]

And lastly, here we have the special entityIf function that defines an entity that exists only while the given predicate is satisfied. In this case, the predicate is (model → notZero). So, as long as the model value is non-zero, the entity will exist. Otherwise, it will not. The engine takes care of creating and destroying the entity accordingly.

In detail, the entityIf function is implemented in terms of the entities function. The entities function defines a list of entities that each exist so long as they have a corresponding mapping from the model. In this way, the Content function is like Elm’s View functions — simulants are automatically created and destroyed by the engine according to whether or not they map from the model. This is the power of declarative programming as enabled by the Elm style.

Now let’s look at Elm-Mario in Nu (see it on Github here) –

41390-198mwhtsmnjytyl6ln2nojw.png

The code here is a bit more involved. It demonstrates how Nu’s uses its scalable built-in physics engine by applying forces rather than using ad-hoc physics routines –

namespace Elmario
open Prime
open Nu
open Nu.Declarative
module Simulants =
// here we create an entity reference for Elmario. This is useful for simulants that you want
// to refer to from multiple places
let Elmario = Default.Layer / "Elmario"
// this is our Elm-style command type
type Command =
| Jump
| MoveLeft
| MoveRight
| Nop
// this is our Elm-style game dispatcher
type ElmarioDispatcher () =
inherit GameDispatcher<unit, unit, Command> (())
// here we channels events to their desired commands
override this.Channel (_, game) =
[game.KeyboardKeyDownEvent =|> fun evt ->
if evt.Data.KeyboardKey = KeyboardKey.Up && not evt.Data.Repeated
then cmd Jump
else cmd Nop
game.UpdateEvent =|> fun _ ->
if KeyboardState.isKeyDown KeyboardKey.Left then cmd MoveLeft
elif KeyboardState.isKeyDown KeyboardKey.Right then cmd MoveRight
else cmd Nop]
// here we handle the Elm-style commands
override this.Command (_, command, _, world) =
let world =
match command with
| MoveLeft ->
let physicsId = Simulants.Elmario.GetPhysicsId world
if World.isBodyOnGround physicsId world
then World.applyBodyForce (v2 -30000.0f 0.0f) physicsId world
else World.applyBodyForce (v2 -7500.0f 0.0f) physicsId world
| MoveRight ->
let physicsId = Simulants.Elmario.GetPhysicsId world
if World.isBodyOnGround physicsId world
then World.applyBodyForce (v2 30000.0f 0.0f) physicsId world
else World.applyBodyForce (v2 7500.0f 0.0f) physicsId world
| Jump ->
let physicsId = Simulants.Elmario.GetPhysicsId world
if World.isBodyOnGround physicsId world then
let world = World.applyBodyForce (v2 0.0f 2000000.0f) physicsId world
World.playSound 0.5f (asset "Gameplay" "Jump") world
else world
| Nop -> world
just world
// here we describe the content of the game including elmario, the ground he walks on, and a rock.
override this.Content (_, _) =
[Content.screen Default.Screen.Name Vanilla []
[Content.layer Default.Layer.Name []
[Content.character Simulants.Elmario.Name
[Entity.Position == v2 0.0f 0.0f
Entity.Size == v2 144.0f 144.0f]
Content.block "Ground"
[Entity.Position == v2 -384.0f -256.0f
Entity.Size == v2 768.0f 64.0f
Entity.StaticImage == asset "Gameplay" "TreeTop"]
Content.block "Rock"
[Entity.Position == v2 320.0f -192.0f
Entity.Size == v2 64.0f 64.0f
Entity.StaticImage == asset "Gameplay" "Rock"]]]]

First we see something new – an explicit simulant reference for our main entity, Elmario –

// here we create an entity reference for Elmario. This is useful for simulants that you want
// to refer to from multiple places
let Elmario = Default.Layer / "Elmario"

This simply allows us to refer to our Elmario entity from multiple places in the code without duplicating its name string.

Also new here is the use of our game physics system. Because the physics here update the engine state (yet still in a purely functional manner), Elm-style commands are used rather than messages.

// this is our Elm-style command type
type Command =
| Jump
| MoveLeft
| MoveRight
| Nop

Here we have three commands, one to move the character left, one to move him right, and one to make him jump! Oh, and then there’s Nop, which is a command that doesn’t do any operations but does allow us to make bindings that may or may not result in a command (as we’ll see below).

// this is our Elm-style game dispatcher
type ElmarioDispatcher () =
inherit GameDispatcher<unit, unit, Command> (())

Since we don’t need a model this time, we simply pass unit for the first type parameter. And since we’re using just commands (no messages this time), we pass unit for the second type parameter and Command for the third type parameter.

Next up, we will use Nu’s ability to define bindings explicitly instead of from simulant property lists –

// here we channels events to their desired commands
override this.Channel (_, game) =
[game.KeyboardKeyDownEvent =|> fun evt ->
if evt.Data.KeyboardKey = KeyboardKey.Up && not evt.Data.Repeated
then cmd Jump
else cmd Nop
game.UpdateEvent =|> fun _ ->
if KeyboardState.isKeyDown KeyboardKey.Left then cmd MoveLeft
elif KeyboardState.isKeyDown KeyboardKey.Right then cmd MoveRight
else cmd Nop]

Let’s break each part down –

[game.KeyboardKeyDownEvent =|> fun evt ->
if evt.Data.KeyboardKey = KeyboardKey.Up && not evt.Data.Repeated
then cmd Jump
else cmd Nop

The first binding works instantaneously on a key down event. When the up arrow key is pressed, the Jump command is issued.

game.UpdateEvent =|> fun _ ->
if KeyboardState.isKeyDown KeyboardKey.Left then cmd MoveLeft
elif KeyboardState.isKeyDown KeyboardKey.Right then cmd MoveRight
else cmd Nop

The second binding we encounter is one that updates every frame. It checks if the left or right arrow keys are down and, if so, issues a command to walk in that direction (otherwise, Nop).

| MoveLeft ->
let physicsId = Simulants.Elmario.GetPhysicsId world
if World.isBodyOnGround physicsId world
then World.applyBodyForce (v2 -30000.0f 0.0f) physicsId world
else World.applyBodyForce (v2 -7500.0f 0.0f) physicsId world

When we encounter the MoveLeft command, we don’t actually move the character by setting its position. For a physics-based entity like the one we have here, that position would only be overridden by the physics system. Instead, when we have a physics-based object, we must issue apply forces to the physics body that correlates with its physics ID. So first, we get the entity’s physics ID, and then we apply a leftward force vector by calling World.applyBodyForce passing the physics ID. Note that we check to see if the physics body is on the ground, and if it is, we apply more force than if it were in the air. This is to limit how much movement can take place while jumping (just like an actual Mario game!)

| Jump ->
let physicsId = Simulants.Elmario.GetPhysicsId world
if World.isBodyOnGround physicsId world then
let world = World.applyBodyForce (v2 0.0f 2000000.0f) physicsId world
World.playSound 0.5f (asset "Gameplay" "Jump") world
else world

Jumping is done similarly, but we also tell the engine to play a jump sound when the entity performs his acrobatic feat.

Having studied the code for previous example, Nelmish, the rest of the code for Elmario should be self-explanatory.

Zoom Out

So that wraps up the introductory explanation, but let’s zoom out to add some interesting conceptual detail. In Nu, unlike Elm, this approach is fractal. Each simulant, be it a Game, a Screen, a Layer, or an Entity, is its own self-contained Elm-style program. In this way, Nu is perhaps more sophisticated than Elm — it’s more like a hierarchy of dynamically-configured Elm programs where each one can be individually loaded by the game as needed, and configured by external programs such as Gaia, the real-time world editor. This gives Nu an additional level of pluggability and dynamism that is required for game development.

Nu’s Elm-style / MVU architecture is a great new way to build games. By leveraging this powerful architecture, we turn game development from being big-ball-of-mud OO nightmare into a task that is fun again!

Likely Questions

Let’s wrap up with some questions that people might be likely to ask about this approach –

Q. “Why not just use Elm to make games?”

A. Well, unfortunately, Elm isn’t really set up for that. Unlike Nu, Elm does not integrate a fast, imperative physics engine. Elm doesn’t include a WYSIWYG editor like Gaia. Elm doesn’t have a game-centric asset pipeline. Elm was not build to scale in the way that a modern game engine must. There are so many game-specific tasks that an engine needs that are entirely out of the scope of Elm’s intended use case. In short, Elm wasn’t designed to build games — but Nu was.

Q. “What about the performance of using this high-level programming interface?”

A. Well, it depends on what you’re using it for. When you’re writing high-level simulants like UI controls, Character Controllers, Static Scenery, Game, Screen, and Layer simulants, this is a sufficiently performant approach in all likely cases. Where it is not sufficiently performant, however, is when encoding things like bullet entities in a bullet hell shooter. The Elm-style API is a good approach by default, but when you need simulants that appear in the ten thousands, you’ll be using a more low-level approach such as with Nu’s data-oriented ECS. That’s good though, because you get simplicity-by-default — and scalability when you need it. That’s what functional programming is all about!

Loading...

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK