Tutorial: making a RPG with Ravioli - 00 - kill monsters
Daniel Neveux
Posted on April 10, 2020
tldr; final result: https://codesandbox.io/s/admiring-turing-tkbwr
Here is the first part on a Ravioli tutorial. We are going to write a RPG from scratch.
We won't use react for this but just some HTML.
The specs.
Here are the specs of our little game:
- as a player, I can beat a monster
- as a player, I can loot a monster
- as a player, I can see my inventory
First it won't be a real game but I will keep this base for futur tutorials and examples and I will enhance it each time.
As a player, I can beat a monster
Beat a monster means that we need... a monster. It will be a kobold!
It also means that our kobold would be alive or dead.
And finally we will need a way to hit it.
Model
Let's begin to write the model. The model is always in a private scope because we don't want our player messes with it
- it won't be accessed by the view.
- the end user is not aware of its internal and can't act directly on it.
To describe a model, we use Crafter which is a core package of Ravioli (and can be used in stand alone but I have no write any doc yet).
Crafter creates a factory which will be used to instantiate our kobold.
Let give to our kobold a name property and health property.
// Model.ts
import { object, string, number, array } from "@warfog/crafter"
export const Kobold = object({
name: string(),
health: number()
})
Cool. Now we are able to create some Kobold!
Test it:
console.log(Kobold.create({name: "Candle master", health: 10}))
Control states
Ok. Back to our spec. We now need to describe those two control states: dead and alive. Let's called them isDead
and isAlive
For those who has never heard of what a control state is, let's resume as: a stable "state" that describes your app.
For our Kobold, as a character it can be "alive" or "dead". Later, when other specs will be treated, maybe it will be "casting" or "fighing".
In Ravioli, we describe those control state as a function of the model. Here how:
// Component.ts
import Ravioli from "@warfog/ravioli";
// Import our model previously created
import { Kobold } from "./Model"
const { component } = Ravioli;
const kobold = Kobold
// A Kobold is alive when its health point are more than 0
.setControlStatePredicate("isAlive", ({ model }) => model.health > 0)
// and dead when its health point are 0 or below
.setControlStatePredicate("isDead", ({ model }) => model.health <= 0)
When you create a kobold you will have access to its control state like this:
const boss = Kobold.create({name: "Candle master", health: 10})) console.log(boss.state.controlStates) // ["isAlive"]
Note the control state appears in an array. In Ravioli we are not force to have only one control state at a time like in a Finite State Machine. Your app can be in mutliple control states if you need to. (more on that later)
Acceptor
So far, we can instantiate a living kobold. Now we need to implement a way for him to die!
As I explained above a control state is a function of the mode. So, we defined an alive kobold and a dead kobold in function of its health points.
So now we need to implement the mutation of the health points.
In Ravioli (and SAM) it is called an Acceptor.
An acceptor has two roles:
- validate the data (is this mutation allowed at this point?)
- do the mutation
For now, we will just do the mutation. And it is quite simple, like in good old javascript. Mutate the data!! Bye, bye reducers.
Immutable lovers, come back. Ravioli mixes the best of the two worlds. Simplicity of mutable code and security of immutable data.
I will details this part in futur doc updates. To make it short, each mutation is an ACID transaction:
- If a transaction throws, the model data won't change.
- Each transaction leads to an immutable, stuctural sharing snapshot so you have still access to a time machine of your component
// Component.ts
...
.addAcceptor("addHealth", model => ({ mutator: ({ hp }) => model.health += hp))
Good, so far we wrote a model which can mutate its health point and be alive or dead.
But as I explain earlier neither the data or the acceptor are accessible. So how the player will be able to hit this monster?
Action!
An action is the only way to (try) to interact with a component.
An action composes a proposal with some mutation the model will maybe accept.
An action is functional, it means that an action name should be meaningful to a user, hidding
the granularity of the model acceptors.
For our case "hit" is a good name for our action. It will return a proposal which removes 3 HP to the kobold.
// Component.ts
...
.addAction({
hit(){
return [{
type: "addHealth",
payload: { hp: -3 }
}]
}
})
Cool. So far, we are able to hit a kobold to death! Let's make a representation to test that.
Representation
Our game won't be a AAA game so let's take some raw HTML to begin.
To show you how reactivity works, let's separate the render from the representation. Our component representation will be an boxed observable primitive, and our render will be a reaction to its updates.
- Write the transformation of the model to its representation.
// Component.ts
...
.setTransformation(
"default", // ID of the transformation
model => `
<h1>RPG Example with Ravioli!</h1>
<div style="display: flex; flex-direction: column; align-items: center">
<i>You are facing a ${kobold.state.representation.name}.</i>
<img src="https://tse2.mm.bing.net/th?id=OIP._bdxk_5JB1Vx631GnMjkrgHaGT&pid=Api&P=0&w=300&h=300" style="margin: 20px"/>
<b style="margin: 20px">Health: ${kobold.state.representation.health}</b>
</div>
`
- Write an autorun which will render the HTML page each time the representation is updated
// Component.ts
import { autorun } from '@warfog/crafter'
// This will run each time representation is updated
autorun(function render() {
document.getElementById("app")!.innerHTML = kobold.state.representation
})
Instantiate a kobold!
It is time to run our app.
// add this line at the end of the component declaration
.create({
name: "Candle Master",
health: 10
})
Houra! You should see an HTML page... but without interaction. It is time to add a...
Hit button
We will extract our markup to a function because the setTransformation does not have access to the actions yet.
Refactoring
Set our representation as an object and extract the markup to a toTHML(representation)
function
// Component.ts
// Replace the transformation
...
.setTransformation("default", model => ({
name: model.name,
image:
"https://tse2.mm.bing.net/th?id=OIP._bdxk_5JB1Vx631GnMjkrgHaGT&pid=Api&P=0&w=300&h=300",
health: model.health
}))
function toHTML(store) {
return `
<h1>RPG Example with Ravioli!</h1>
<div style="display: flex; flex-direction: column; align-items: center">
<i>You are facing a ${store.name}.</i>
<img src="${store.image}" style="margin: 20px"/>
<b style="margin: 20px">Health: ${store.health}</b>
</div>
`
}
autorun(function render() {
document.getElementById("app")!.innerHTML = kobold.state.representation
})
Extract the actions
const {hit} = kobold.representation.actions
Add a Hit button
Let's do it quick and dirty, for the HTML page to access the actions, we set it as a global object
window.hit = function() {
kobold.actions.hit();
};
window.loot = function() {
if (kobold.state.representation.loot.length) {
kobold.actions.drop(kobold.state.representation.loot[0].id);
}
};
`
...
<b style="margin: 20px">Health: ${store.health}</b>
// Add the button
<button onClick="hit();">Hit!</button>
</div>
`
Refresh your app. Click on hit and beat it!!
Oh wait. The health is now below 0 :(
To fix this we set a second transformation when the kobold is dead.
Bind representation to control states
Refactor
To map a transformation to a control state, we just need to pass the desired control state to the setTransformation
function.
// replace the previous transform
.setTransformation("alive", {
predicate: "isAlive", // This transformation will only be used when the kobold is alive.
computation: model => ({
name: model.name,
image:
"https://tse2.mm.bing.net/th?id=OIP._bdxk_5JB1Vx631GnMjkrgHaGT&pid=Api&P=0&w=300&h=300",
health: model.health
})
})
Also rename the function toHTML
to isALive
Add a representation when the kobold is dead
// place this one just after the "alive" transform
.setTransformation("dead", {
predicate: "isDead",
computation: model => ({ name: model.name })
})
Add a new html markup function
function isAlive(store: AliveKobold) {
...
}
function isDead(store: DeadKobold) {
return `
<h1>RPG Example with Ravioli!</h1>
<div style="display: flex; flex-direction: column; align-items: center">
<i>You have killed the ${name: store.name}, loot it.</i>
</div>
`;
}
Refresh the app. Hit the Kobold. Now, when the component reaches the "isDead" control state, a new view is displayed! Congratulations, you beat the Candle Master!
Our first ticket is complete!! Next!
As a player, I can loot a monster
Its body still warm, you decide to loot your fallen ennemy.
For this ticket, we will only focus on the loot of the kobold. Not the inventory of the player.
First our model will need an inventory. Second, we will need an acceptor to remove item from this inventory. Last, we will need an action to pick up an item. Let's go.
Add the inventory shape to the model
We will add a field inventory which is an array of item in our model.
...
// place this in the root model object
inventory: array(object{
id: string(), // item id
quantity: number() // slot quantity
})
...
Add acceptor
We now need to add an acceptor for removing an item from the inventory. Note again, the name and the purpose are not revelant from a functional point of view but are from a business point of view.
.addAcceptor("removeFromInventory", model => ({ mutator: ({id}) => {
model.inventory.splice(model.inventory.findIndex(item => item.id === id), 1)
}}))
Add action
.addActions({
hit()...,
dropItem({ id }: { id: string }) {
return [{
type: "removeFromInventory",
payload: { id }
}]
}
})
Wait a minute. Is there any Vuex users here? Does this sound familliar to you? I mean this implementation does not do anything other than passing the payload
to a mutation.
For this case that you may encouter multiple times, there is a shortcut.
.addActions({
hit()...,
// Again, note the difference of meaning between a functional action and a business mutation.
dropItem: "removeFromInventory"
})
You are welcome.
Add loot button
As for hit button, let's add a global function loot
to trigger the actions.
window.loot = function() {
if (kobold.state.representation.loot.length) {
kobold.actions.drop(kobold.state.representation.loot[0]);
}
}
Note this is an example of an anti pattern.
Indeed the
if
condition is a business rule. In a game it is the role of the Game Master to accept or reject the pick action, because it knows about what is inside inventory of all players and NPCs.Still, it works, but it could be a vulnerability in our game. If the client manages to by pass this condition, it will be able to pickup as many item as he wants.
Never trust the client! 100% of cheaters are players!
We will refactor this in a futur step.
Update the html markup
We will display the inventory of the dead kobold. Update the isDead
function with this:
function isDead(store: DeadKobold) {
return `
...
<i>You have killed the ${name: store.name}, loot it.</i>
<ul>
${store.loot.map(({ id }) => `<li>${id}</li>`).join("")}
</ul>
<button onClick="loot()">Loot</button>
</div>
`
Now, refresh and rekill the kobold and loot it. Its loot list should empty.
Congratulations, we have completed our second specs: As a player, I can loot a monster
Now let's make this player more tangible.
As a player, I can see my inventory
There are two ways to solve this. Use a second component or update the existing one to add a player field.
For the sake of the example I will go to add a second component to show you that you can use more than one data store in your app.
Model
Our player needs only an inventory field.
// Model.ts
export const Player = component(
object({
inventory: array(
object({
id: string(),
quantity: number()
})
)
})
)
Add acceptor
We just need to be able to add an item to the inventory.
// Component.ts
import { Kobold, Player } from './Model'
...
Player
.addAcceptor("addToInventory", model => ({
mutator(item: { id: string; quantity: number }) {
model.inventory.push(item);
}
}))
Add action
Let's do it simple. As we have access to the ids of the loot. We can pass it to the action. Then the role of the action will be to ensure that just one item will be added.
// Component.ts
...
.addAction({
pickItem: function({ id }: { id: string }) {
return [
{
type: "addToInventory",
payload: { id, quantity: 1 }
}
];
}
})
Now we will trigger our action in the same global loot
function. Two actions on two
different components will be triggered sequentially.
The autorun
which renders the app will update two times.
// Component.ts
window.loot = function() {
if (kobold.state.representation.loot.length) {
player.actions.pickItem(kobold.state.representation.loot[0]);
kobold.actions.drop(kobold.state.representation.loot[0].id);
}
};
Representation
This time, our representation does not have to access any action. So we will set it up directly in the component.
// Component.ts
...
.setTransformation('default', model => `
<h2>Player inventory:</h2>
${
model.inventory.length
? `
<ul>
${model.inventory.map(({ id }) => `<li>${id}</li>`).join("")}
</ul>
`
: "empty :("
}
`)
Instantiation
Let's create it! Hint, when you don't pass any value to create, the factory will get the default value of each type ('' for string, NaN for number, [] for array...)
// Component.ts
...
.create()
And voilà! Now your player sees its inventory picking each items on the dead Kobold.
Conclusion
This was a detailled explanation on how Ravioli works with a simple example. There is field of improvements which will be treated in the same serie.
- merge the two components into a single tree to
- make one render when clicking on loot instead of two
- improve security to prevent user to add too many items
Posted on April 10, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.