-
Notifications
You must be signed in to change notification settings - Fork 0
Home
Code repository: https://github.com/meteor-leuven/agar
Table of contents
[TOC]
This document is part of a workshop given by Meteor Leuven. Meteor Leuven is a Meetup group founded by three Meteor enthousiasts: Jeroen, Damiaan and Kymer. Our group is officially supported by Meteor: Nick Coe, community manager at Meteor who runs the global Meetup program for Meteor, co-founded the group.
If you liked this workshop and can't get enough of Meteor, make sure to join our Meetup group to meet like-minded people in and around Leuven.
For any questions or feedback related to this workshop you can contact Damiaan and Kymer on Twitter: @Dev1an or @Kymer_G. Alternatively use the contact form on the Meteor Leuven Meetup page and we'll get back to you.
Follow the steps at: meteor.com/install
On Windows you might be prompted to create a Meteor account: this is not mandatory, you can skip that step.
If you already had Meteor installed, make sure your installation is up to date:
meteor update
Using Terminal or CMD command prompt, type:
meteor create agar # creates a meteor application in the directory ./agar/
cd agar # changes directory to agar
meteor #starts the meteor application residing in the current (agar) directory
If Windows Firewall blocked something (e.g. Evented I/O for V8 JavaScript) you can safely click allow access for both public and private networks.
Surf to http://localhost:3000 and admire your new meteor application.
To make your life easier you should use an IDE (integrated development environment) or a capable code editor. Although I intensely hate Java applications, WebStorm is the handiest one available right now, with support for Meteor out of the box. Alternatively, you can also use Sublime Text or Atom, but those require some additional set-up. Have a look at the Meteor website for some suggestions and links to the useful Meteor plugins.
Open the agar project directory in your editor and play around a bit. Don't be afraid to break things, we will not use the default sample files, in fact after you are done it's time to clean up the project.
Meteor added some sample files to get you started. Your project should look like this:
├── client
│ ├── main.css
│ ├── main.html
│ └── main.js
├── package.json
└── server
└── main.js
2 directories, 5 files
If you don't see the client
and server
folder you probably have an outdated version of Meteor installed. In that case take a look at step 1, update Meteor and recreate the agar project.
Delete all the html
, css
, js
and json
files. That leaves you with a nice, fresh, empty project:
├── client
└── server
2 directories, 0 files
Note: on Windows you will see files and folders starting with a dot, those are hidden on Mac and Linux and should not be deleted.
Okay, it's time to start drawing circles in your browser!
Add an empty canvas.html
file inside the client
directory. You can use the command line to quickly do this. The file should automatically appear in your editor.
Mac / Linux:
touch client/canvas.html #creates an empty file inside the client directory
Windows:
type nul >> client/canvas.html
Add a <head>
and a <body>
element to your HTML file. Inside the <body>
you will add an SVG (Scalable Vector Graphics) element. SVG elements have their own viewports and coordinate systems.
We want to render the circle of the player in the center of the screen. The coordinate system looks like this, however:
Note how the top-left corner has the coordinate (0,0).
We want the circle you control to appear in the middle of the screen. It would make sense to have (0,0) as our center coordinate. We all know you are the center of the universe, aren't you? Therefore, when creating the <svg>
element you should make sure the viewBox
top-left corner is set at (-200, -200) and has a height and width of 400. (these are so-called user units, in case you're wondering).
Here's a little code snippet to get you on your way:
<svg viewBox="-200 -200 400 400">
<!-- your balls here! -->
</svg>
For more information read the excellent blogpost "Understanding SVG Coordinate Systems and Transformations".
Now add some circles:
- A circle in the middle (which you will control later on)
- A few smaller "food" circles scattered around the field
- A few bigger circles representing other players
Here is how you create a basic circle element:
<circle cx="0" cy="0" r="20" fill=lightBlue />
Take a look at this list of named colors if you want to play around with different colors.
As you may have noticed, the viewport does not fit the browser window. If you make your browser smaller, the view gets clipped. To fix that we'll need to throw some CSS into the mix.
Create a new css
file in the client
folder. Using CSS you can scale the <svg>
element to fit the browser window.
Mac / Linux:
touch client/stylesheet.css
Windows:
type nul >> client/stylesheet.css
Inside the css
file you should add some rules which apply to the height
and width
of the <svg>
element:
svg {
/* your rules here */
}
Here's a tip, take a look at relative css lengths.
Okay now you have a feel for how to draw visuals in a browser window. You created some circles and fooled around with the coordinates and radii (strange word, but radiusess isn't much better).
Here's the problem: you hardcoded the circles onto the canvas. The data ( x, y, radius) and the UI (the circle element) are tightly coupled. The amount of food circles and player circles is hardcoded as well. We don't want that, those values are supposed to be variable and updating those variables is now way too complicated. It's time to decouple!
What data defines a food entity and player entity? The location on the map is definitely something that shouldn't be hardcoded. Neither should the number of entities on the playing field. The radius of a player is variable as well, as it will depend on the number of food items they have eaten. (don't worry about your own circle, we'll tackle that in our next step)
Removing all that from the <svg>
element inside canvas.html
gives you something like this:
<!-- for each food entity, draw a new circle -->
<circle cx='x value of food' cy='y value of player' r=5 fill=black />
<!-- for each player entity, draw a new circle -->
<circle cx='x value of player' cy='y value of player' r='radius of player' fill=orange />
That will obviously not compile. Luckily the real solution is not that much different from what you see above. Here is where we start exploiting the true power of Meteor.
Create an empty JavaScript file.
Mac / Linux:
touch client/canvas.js
Windows:
type nul >> client/canvas.js
This JavaScript file will contain helper functions and other handy properties that can be used in the canvas.html
file. Here is how you add helpers to the <body>
element:
JavaScript inside canvas.js
Template.body.helpers({
food: [
// food entities
],
otherPlayers: [
// player entities
]
})
We added 2 empty arrays in the helpers object. Using the example below, you should be able to add the necessary food and player data and render the circles in the browser.
JavaScript
Template.body.helpers({
heroes: [
{name: "Finn", sidekick: "Jake"},
{name: "Batman", sidekick: "Robin"}
]
})
HTML
<body>
{{#each hero in heroes}}
<p>{{hero.name}} & {{hero.sidekick}}</p>
{{/each}}
</body>
Result
Finn & Jake
Batman & Robin
The double curly braces {{
and }}
are Spacebars. They are part of Meteor's templating system called Blaze. You can use them to iterate over 'collections' of data (such as an array, in the example above), access variables, include other HTML snippets (called templates), to control the code flow, etc… Don't worry too much about them, just remember that whenever you see those there is some 'logic' involved.
Remember how you were the center of the universe in step 2.1? That's exactly where you remain... even if you are moving. "That's impossible!" you might scream, to which we reply: "No, because all motion is relative!".
Yeah, that's what Albert said. It might not feel intuitive, but it does simplify the code.
You moving up is exactly the same as the rest of the world moving down. That's how we will do things in our code as well. We will translate all other entities in the opposite direction you are 'moving' in while you remain stationary in the center of the screen. That way we don't have to worry about your circle moving off screen and don't have to implement a virtual 'camera' that tracks your location.
First, add some extra helpers in canvas.js
:
Template.body.helpers({
me: {x: 10, y: 0, radius: 10},
invert(number) {return -number},
// ...
})
me
is a plain JavaScript object with some data properties, invert
is a simple JavaScript function which inverts a given number.
Sidenote: If you are using Webstorm and get the following error on the invert
function:
Method definition shorthands are not supported by current JavaScript version
You should go to the preferences (on OS X ⌘+,) and in the top-left corner search for "javascript language version". Under Languages & Frameworks > JavaScript
set the JavaScript version to ECMAScript 6 and you are good to go!
Time to decouple your own radius. Do not make use of the me
x and y values, leave them at (0,0).
<circle cx=0 cy=0 r={{me.radius}} fill=lightblue />
Instead, make use of the invert
function to move all other entities in the opposite direction of me
. To easily apply a translation to all entities they should be grouped. Move the food and player circles inside a <g>
element and apply a translate(x y)
transformation on the <g>
element:
<g transform="translate(x y)" >
<!-- food and players -->
</g>
Make use of the newly created helpers to fill in the correct x and y values. Here is how you can call a helper function with a parameter using the Spacebars syntax:
{{function parameter}}
If you have done that you are now you are ready to move within the world. Play around with the x and y values of me
in canvas.js
and save the file. Because of Meteor's hot code push the browser should automatically refresh and it will look like your circle is moving!
You might ask yourself why you did all that work to decouple the data from the UI, only to hardcode the circles in a different place. Well it's time to get real. So ladies and gents: hold on to your hats, it's time to... synchronize THE WORLD!
That sounded rather daunting, didn't it? Well it shouldn't. We are working with Meteor, after all. Everything is pretty straightforward.
Start by creating a new directory called lib
inside the agar
project:
Mac / Linux / Windows:
mkdir lib
You should now have 3 folders: a client
, server
and lib
folder. Files in the client are only accessible on the… client, no surprises there. Idem dito for the server. Files in the lib folder are accessible to both client and server.
First we need something to store the food entities in. Meteor uses MongoDB as its database by default. We need to define a collection which is accessible to both server and client. The server will insert (write) the food entities into the database, the client will read from the database. (remember how you iterated over an array in step 3, well you can iterate over a Mongo collection just as easily)
Create a new JavaScript file called collections.js
inside the lib
folder.
Mac / Linux:
touch lib/collections.js
Windows:
type nul >> lib/collections.js
And create a food collection like this:
Food = new Mongo.Collection('food')
Food circles should be scattered around the playing field in a randomly fashion. And you need about 200 circles. You don't want to hardcode those all by hand, now do you?
Let's create a function inside the same collections.js
file which returns a random coordinate:
randomLocation = function() {
return {
x: 'random value',
y: 'random value'
}
}
Take a look at MDN to figure out how to return random numbers:
The Math.random() function returns a floating-point, pseudo-random number in the range [0, 1) that is, from 0 (inclusive) up to, but not including, 1 (exclusive), which you can scale to your desired range.
Make sure your randomLocation
function will generate random x and y values between -500 and 499, not 0 and 0.999.
Time to put your shiny new method to the test. Let's create a food generator!
Create a new foodGenerator.js
file inside the server
folder:
Mac / Linux:
touch server/foodGenerator.js
Windows:
type nul >> server/foodGenerator.js
And create a for-loop to populate the food collection with 200 random coordinates:
for (let foodCount = 0; foodCount < 200; foodCount++) {
Food.insert(randomLocation())
}
For those of you who have never seen a construction like that before, let me break it down for you. It creates a new variable called foodCount
. As long as foodCount
is strictly smaller than 200 it will run the Food.insert()
function and increment the counter by 1.
Somewhere in your helpers in the canvas.js
file you should have this:
Template.body.helpers({
food: [
{x: -100, y:-30},
{x: 30, y: 130},
{x: 87, y: 100}
],
// ...
})
food
is in this case a plain JavaScript array. You use it in your UI to iterate over the contents using the Spacebars {{#each }}
syntax. All you need to do is change food
from being an array to be a function, which returns something like an array. Remember: the Food
collection is defined in the lib
folder, i.e. you can access it from the client:
Template.body.helpers({
food() {return Food.find()},
// ...
})
Food.find()
(without any parameters) will return a cursor pointing to all food
entities:
A pointer to the result set of a query. Clients can iterate through a cursor to retrieve results.
That's all we need! Save your file and look at all the glorious food balls appearing in your browser!
But there's a problem, everytime the server restarts (e.g. when you save a file) 200 food circles are added to the field. Try adjusting the for-loop on the server so there are at most 200 circles on-screen at once.
Hint: you can call the count()
method on a cursor:
Append the count() method to a find() query to return the number of matching documents.
Well, that wasn't that hard now was it? Time to do the same for all the player entities.
Create a new collection called Players
in the collections.js
file in the lib
folder.
In the server
folder create a new file called playerBinding.js
. This does exactly what it sounds like:
// When a person opens the browser game
Meteor.onConnection(function(connection) {
// 1. create a new player object
const newPlayer = randomLocation()
newPlayer.points = 0;
// 2. add player object to database
const playerId = Players.insert(newPlayer)
// 3. link the unique ID to the current connection
connection.playerId = playerId
// When a person closes the browser game
connection.onClose(function() {
// remove the player from the database (using the unique ID)
Players.remove(playerId)
})
})
// this method returns the ID of the current connected user
Meteor.methods({
getMyPlayerId() { return this.connection.playerId }
})
// empties the Players collection
Players.remove({})
That's all you need to handle players connecting and disconnecting to the game.
We need to add a package to our project in preperation for the next bit. Open your Terminal app or Command prompt, navigate to your project folder and run:
meteor add reactive-var
A reactive variable is a special kind of variable. If it changes, all the places where you used that variable are also reactively updated. Automagically! (what a horrible word)
In the client
folder create a new file called player.js
Mac / Linux:*
touch client/player.js
Windows:
type nul >> client/player.js
// make the myPlayerId variable importable in other files
export const myPlayerId = new ReactiveVar()
// if a player is connected, fetch his/her ID and put it in the reactive var
Tracker.autorun(function() {
if (Meteor.status().connected)
Meteor.call(
'getMyPlayerId',
(error, result) => myPlayerId.set(result)
)
})
From the Meteor documentation:
Tracker.autorun allows you to run a function that depends on reactive data sources, in such a way that, if there are changes to the data later, the function will be rerun.
Add this at the top of the canvas.js
file in the client
folder:
import { myPlayerId } from './player'
Now we can access the playerId
of the current player inside throughout the canvas file.
Update your me
and otherPlayers
helpers so they make use of the Players collection and add a new helper function called radiusFor
:
Template.body.helpers({
me() {
return Players.findOne(myPlayerId.get())
},
otherPlayers() {
// returns the entire collection except for the current player
return Players.find({_id: {$not: myPlayerId.get()} })
},
radiusFor(player) {
return 10 + player.points
},
// ...
})
Update your UI in canvas.html
to make use of the new helpers:
{{#each player in otherPlayers}}
<circle cx={{player.x}} cy={{player.y}} r={{ ??? }} fill=orange />
{{/each}}
{{#if me}}
<circle cx=0 cy=0 r={{ ??? }} fill=lightblue />
{{/if}}
You need to call the radiusFor
helper and give it a player
object as parameter.
Done!
Ask your neighbor to 'surf' to your (internal) IP address followed by the port number like so:
http://192.168.0.191:3000
He or she should be able to connect to your game! They can't move yet, so let's implement movement next in step 6.
Note: your neighbor must be on the same network as you for this to work
It's time to take control! We will use the coordinate of the mouse pointer to determine the direction a player wants to move in and we will update the player coordinate by continously calling a method that updates our x and y values according to the direction.
Inside the player.js
file add the following code:
// ...
// Movement
export var direction = {x: 0, y: 0, normalize() {
const length = Math.hypot(this.x, this.y)
this.x /= length
this.y /= length
}}
var myVelocity = 3
The direction
variable represents a vector of the direction the user is moving to. A vector is defined by its length (or magnitude) and direction. We are only interested in its direction, so each time the x and y properties are changed the vector should be normalized. A normalized vector is nothing more than a vector with a length of 1.
Don't worry about setting the x and y values of the direction, we'll do that somewhere else.
Now we need to make sure the player coordinate in the Players
collection gets continously incremented (note the $inc
, that's Mongo syntax to increment a value) to make the player move. Add this snippet and fill in the blanks. Make use of the direction
and velocity
variables.
window.requestAnimationFrame(function updateMyPosition() {
Players.update(myPlayerId.get(), {$inc: {
x: ???,
y: ???
}})
window.requestAnimationFrame(updateMyPosition)
})
The window.requestAnimationFrame()
is pretty important. It makes our game 'run'. From MDN:
The Window.requestAnimationFrame() method tells the browser that you wish to perform an animation and requests that the browser call a specified function to update an animation before the next repaint.
Inside that function we call the same function again. That's called recursion. We create an infinite loop on purpose. This is the so called game loop:
The goal of every video game is to present the user(s) with a situation, accept their input, interpret those signals into actions, and calculate a new situation resulting from those acts. Games are constantly looping through these stages, over and over, until some end condition occurs (such as winning, losing, or exiting to go to bed). Not surprisingly, this pattern corresponds to how a game engine is programmed.
The x and y values of the direction vector need to be correctly calculated for that loop to work of course. So import
the direction object in the canvas.js
file and add an event listener to the <body>
element like so:
import { myPlayerId, direction } from './player'
Template.body.helpers({
//...
}}
Template.body.events({
'mousemove svg'(mouse) {
direction.x = mouse.clientX - mouse.currentTarget.offsetWidth /2
direction.y = mouse.clientY - mouse.currentTarget.offsetHeight/2
direction.normalize()
}
})
Just like you added helpers to the <body>
template, you can add event listeners and, well… 'listen' for events. This enables you to do extra stuff every time the event occurs, stuff like updating the direction vector. Neat!
From MDN:
The mousemove event is fired when a pointing device (usually a mouse) is moved while over an element.
So each time the mouse moves over the <svg>
element you update the direction vector. The direction starts from the center of the screen and points to where the cursor is located.
The clientX
and clientY
properties represent the x and y values of the mouse in the local DOM element, in other words: the coordinate inside the <svg>
element.
mouse.currentTarget
is in our case the <svg>
element, offsetWidth
and offsetHeight
is the current width and height of the element and we need those to calculate the center of the screen.
Save the file and check your browser: you are skating accross the field! Feel free to play around with your velocity. This is starting to look like a real game.
Right now the player is able to move way too far from the playing field. We need to set some boundaries.
Let's create a grid to better visualize the playing field. Inside canvas.html
add to the <svg>
element:
<g transform="translate({{invert me.x}} {{invert me.y}})">
<pattern id=grid20 width=20 height=20 patternUnits=userSpaceOnUse>
<path d="M 20 0 L 0 0 0 20" fill=none stroke=gray stroke-opacity=0.2 stroke-width=2 />
</pattern>
<rect height=1001 width=1001 y=-500 x=-500 fill=url(#grid20) />
<!-- food and players -->
</g>
The <path>
element has a 'draw' attribute called d
which describes that path. From MDN:
The d attribute is actually a string which contains a series of path descriptions. These paths consist of combinations the following instructions:
Moveto Lineto
[…]
Moveto instructions can be thought of as picking up the drawing instrument and setting it down somewhere else. Unlike Moveto instructions, Lineto instructions will draw a straight line. This line moves from the current position to the specified location.
Save the file and look at your browser, you should see something like this:
Okay let's make sure the player can't move off the grid. Inside the player.js
file add a new variable called myPosition
:
const myPosition = {x: 0, y: 0}
Now we need to keep this variable up to date each time the position changes. A perfect task for the Tracker. Add this in the same player.js
file:
Tracker.autorun(function() {
const me = Players.findOne(myPlayerId.get())
if (me != undefined) {
myPosition.x = me.x
myPosition.y = me.y
}
})
Each time the player position changes, Meteor runs that function and, as a result, the myPostion
variable gets updated. We need the position to determine whether the player is moving out of bounds. So update the updateMyPosition()
function:
window.requestAnimationFrame(function updateMyPosition() {
Players.update(myPlayerId.get(), {$inc: {
x: clamp(direction.x, myPosition.x) * myVelocity,
y: clamp(direction.y, myPosition.y) * myVelocity
}})
window.requestAnimationFrame(updateMyPosition)
})
As you can see it now makes use of a function called clamp(direction, position)
that takes 2 numbers as parameter. It's your turn to create that function!
There are 3 possible scenarios to consider:
- You are inside the bounds: you can continue moving in either direction.
- You just crossed the bounds and are moving away from the playing field: your circle should stop moving.
- You just crossed the bounds but changed direction and are now moving towards the playing field: your circle should continue moving (towards the playing field).
The clamp
function should return a number. E.g. in scenario 2 the function should return 0 because 0 * myVelocity
means your circle stops moving. In scenario 1 and 3 you can simply return the direction
parameter, because the player may continue in that direction.
Hint: these Math
functions may come in handy:
Math.abs(number)
:
The Math.abs() function returns the absolute value of a number
Math.sign(number)
:
The Math.sign() function returns the sign of a number, indicating whether the number is positive, negative or zero.
Didn't your mother tell you not to play with your food?!
Well in our case, we definitely should. So follow along!
Here is what needs to happen:
- Find food circles which are close to you
- Check if any of those food circles overlap with your circle
- If we detect an overlap:
- Remove the food circle from the Food collection
- Insert a new food circle at a random location in the Food collection
- Update your score / points in the Players collection
- Lower your velocity (fatter = slower)
Add a new function called eatWhenPossible
which takes a player
as paramater inside player.js
// ...
// Eating behavior
function eatWhenPossible(player) {
const radius = player.points+10
// 1.
Food.find({
x: {$gte: ??? , $lte: ??? },
y: {$gte: ??? , $lte: ??? }
// 2.
}).forEach(function(pieceOfFood) {
if ( ??? ) {
// 3.
Food.remove(pieceOfFood._id)
Food.insert(randomLocation())
Players.update(player._id, {$inc: {points: 1}})
myVelocity *= 0.95
}
})
}
It is up to you to fill in the blanks.
The $gte
Mongo syntax means greater than or equal, $lte
means, not surprisingly, less than or equal. Makes sense, right?
To check if 2 circles overlap, we need to know their distance and both their radii. The radius is readily available. We do need to calculate their distance though. Take a look at this, it should immediately make you think of something:
The Pythagorean theorem! Yes, you are correct. Take a look at Wikipedia if you need a reminder. Tip: use the Math.hypot()
function:
The Math.hypot() function returns the square root of the sum of squares of its arguments
From: MDN
The eatWhenPossible
method should be called each time the player moves. So call it inside the autorun method:
Tracker.autorun(function() {
const me = Players.findOne(myPlayerId.get())
if (me != undefined) {
myPosition.x = me.x
myPosition.y = me.y
eatWhenPossible(me)
}
})
We need a new property on player entities, so in the file playerBinding.js
inside the server
folder, add a gameOver
property and set it to false
like so:
Meteor.onConnection(function(connection) {
// ...
newPlayer.gameOver = false
// ...
})
We only want to show the circles of player who's game is not over, so update the otherPlayers
helper inside the canvas.js
file:
Template.body.helpers({
otherPlayers() {
return Players.find({
_id: {$not: myPlayerId.get()},
gameOver: false
})
},
// ...
})
Lastly, add the logic to detect a collision with other players in the eatWhenPossible(player)
method inside the player.js
file.
Here is what needs to happen:
-
Find player circles in the Players collection which are close to you, and
1. Are not *you*, 2. Are smaller than you, 3. Whose game isn't over.
-
Check if any of those other player circles overlap with our circle
-
If we detect an overlap:
- Set
gameOver
property of other player totrue
- Update your score / points in the Players collection
- Lower our velocity (fatter = slower)
- Set
//Eat other players
// 1.
Players.find({
_id: {$ne: player._id},
points: {$lt: player.points},
x: {$gte: ??? , $lte: ??? },
y: {$gte: ??? , $lte: ??? },
gameOver: {$ne: true}
// 2.
}).forEach(function(otherPlayer) {
if ( ??? ) {
Players.update(otherPlayer._id, {$set: {gameOver: true}})
Players.update(player._id, {$inc: {points: otherPlayer.points+1}})
myVelocity *= 0.95
}
Again, fill in the blanks. Once you are done, save the file and open your browser!
Congratulations, you have built a basic prototype of a multiplayer browser game. Isn't that neat?
There are still lots of features that can be added, some easier than others. With the knowlegde you now have you should be able to very easily implement random colors. Up till now you used named colors, but SVG elements can use RGB colors as well of course.
Because we're working with SVG, adding skins should not be that hard either. You can take any vector graphic and export it to SVG. For example, this Meteor logo:
If exported from e.g. Illustrator to SVG is nothing more than a bunch of <path>
elements:
<path d="M0.215,0.215L107.502,113.857C107.502,113.857 111.157,116.435 113.952,113.428C116.747,110.42 114.597,107.413 114.597,107.413L0.215,0.215Z" style="fill:rgb(223,79,79);"/>
<path d="M34.186,10.956L115.887,99.034C115.887,99.034 119.542,101.612 122.337,98.605C125.132,95.597 122.982,92.59 122.982,92.59L34.186,10.956Z" style="fill:rgb(223,79,79);"/>
<path d="M10.32,33.942L92.021,122.021C92.021,122.021 95.676,124.599 98.471,121.591C101.267,118.583 99.117,115.576 99.117,115.576L10.32,33.942Z" style="fill:rgb(223,79,79);"/>
<path d="M62.903,20.194L119.983,81.729C119.983,81.729 122.537,83.53 124.489,81.428C126.442,79.327 124.94,77.226 124.94,77.226L62.903,20.194Z" style="fill:rgb(223,79,79);"/>
<path d="M18.182,60.581L75.262,122.116C75.262,122.116 77.816,123.917 79.769,121.816C81.721,119.714 80.219,117.613 80.219,117.613L18.182,60.581Z" style="fill:rgb(223,79,79);"/>
<path d="M92.236,33.513L118.107,61.487C118.107,61.487 119.37,62.331 120.336,61.347C121.302,60.362 120.559,59.377 120.559,59.377L92.236,33.513Z" style="fill:rgb(223,79,79);"/>
<path d="M32.465,88.938L58.336,116.912C58.336,116.912 59.599,117.756 60.565,116.771C61.531,115.787 60.788,114.802 60.788,114.802L32.465,88.938Z" style="fill:rgb(223,79,79);"/>
There's no black magic involved. Just plain old paths. You would of course have to define that in a seperate file, otherwise your canvas.html
will get way too crowded. But you get the gist.