Electron Adventures: Episode 71: CoffeeScript Phaser Game
Tomasz Wegrzanowski
Posted on October 3, 2021
Now that we have CoffeeScript 2 setup, let's create a simple game with Phaser 3.
js2.coffee
This is my first time writing new CoffeeScript in years, and I quickly discovered how painful lack of working js2.coffee is. The existing converter only handles pre-ES6 JavaScript, and even that often doesn't generate great code. Being able to convert between JavaScript and CoffeeScript easily was a huge part of CoffeeScript's appeal at the time, and it's now completely gone.
Not that there's anything too complicated about converting JavaScript to CoffeeScript manually, but it's pointless tedium in a language whose primary appeal is cutting on pointless tedium.
Asset files
I emptied preload.coffee
as we won't need it.
I added star.png
and coin.mp3
to public/
. There's a lot of free assets on the Internet which you can use in your games.
We'll also need to npm install phaser
public/index.html
Here's the updated index.html
file, just loading Phaser, and adding a placeholder div for game
canvas
to be placed at:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="app.css">
</head>
<body>
<div id="game"></div>
<script src="../node_modules/phaser/dist/phaser.js"></script>
<script src="./build/app.js"></script>
</body>
</html>
public/app.css
To keep things simple I decided to just center the game canvas in the browser window, without any special styling:
body {
background-color: #444;
color: #fff;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
#game {
}
Game source
Let's go through the game code. It's something I wrote a while ago, and just slightly adapted and converted to CoffeeScript for this episode.
Getters and setters
When CoffeeScript adapted to ES6, a few features were really difficult to add due to syntactic issues.
Dropping some features made sense, like the whole var/let/const
mess. JavaScript would do just fine having one way to define variables - namely let
. You might have noticed by now that I never use const
- if variables declared const
s were actually immutable, I might change my mind, but I find it both pointless extra thing to think about, and also intentionally misleading. Declaring mutable state with const, as is the standard React Hooks way (const [counter, setCounter] = useState(0)
), looks like a vile abomination to me. So CoffeeScript never bothering with three variable types makes perfect sense.
Much more questionable is not having getters and setters. They can be emulated with calls to Object.defineProperty
, but these are ugly and are in wrong place - in constructor instead of being part of class definition. Well, we'll just use what we have, so here's the getter helper:
get = (self, name, getter) ->
Object.defineProperty self, name, {get: getter}
Start game
We define constant size box and create a game using MainScene
class.
size_x = 800
size_y = 600
game = new Phaser.Game
backgroundColor: "#AAF"
width: size_x
height: size_y
scene: MainScene
StarEmitter
When a ball hits a brick, we want to do some fancy effects. An easy effect is bursting some stars, and it's so common Phaser already contains particle emitter system. Here's a class that sets up such emitter with some settings how those stars should fly.
class StarEmitter
constructor: (scene) ->
@particles = scene.add.particles("star")
@emitter = @particles.createEmitter
gravityY: -50
on: false
lifespan: 2000
speedX: {min: -50, max: 50}
speedY: {min: -50, max: 50}
alpha: 0.2
rotate: {min: -1000, max: 1000}
burst_at: (x, y) ->
@emitter.emitParticle(40, x, y)
Brick
class Brick
constructor: (scene, x, y) ->
colors_by_row = {
2: 0xFF0000
3: 0xFF0080
4: 0xFF00FF
5: 0xFF80FF
6: 0x8080FF
7: 0x80FFFF
}
@destroyed = false
@brick_x_size = size_x/18
@brick_y_size = size_y/30
@brick = scene.add.graphics()
@brick.x = x*size_x/12
@brick.y = y*size_y/20
@brick.fillStyle(colors_by_row[y])
@brick.fillRect(
-@brick_x_size/2, -@brick_y_size/2,
@brick_x_size, @brick_y_size
)
get @, "x",-> @brick.x
get @, "y",-> @brick.y
destroy: ->
@brick.destroy()
@destroyed = true
Brick
is a straightforward class wrapping Phaser brick
object. You can see how one can do getters in CoffeeScript. It works, but it's a bit awkward.
The only method Brick has is destroy
.
Ball
class Ball
constructor: (scene) ->
@ball = scene.add.graphics()
@ball.x = 0.5*size_x
@ball.y = 0.8*size_y
@ball.fillStyle(0x000000)
@ball.fillRect(-10,-10,20,20)
@dx = 300
@dy = -300
get @, "x", -> @ball.x
get @, "y", -> @ball.y
update: (dt) ->
@ball.x += @dx*dt
@ball.y += @dy*dt
if @ball.x <= 10 && @dx < 0
@dx = - @dx
if @ball.x >= size_x-10 && @dx > 0
@dx = - @dx
if @ball.y <= 10 && @dy < 0
@dy = - @dy
The Ball
has similar messy getter. The only method is update
which is passed how much time passed since the last update, and it's responsible for ball bouncing off the walls, but not bouncing off paddle or bricks.
Paddle
class Paddle
constructor: (scene) ->
@paddle = scene.add.graphics()
@paddle.x = 0.5*size_x
@paddle.y = size_y-20
@paddle.fillStyle(0x0000FF)
@paddle.fillRect(-50, -10, 100, 20)
get @, "x", -> @paddle.x
update: (dt, direction) ->
@paddle.x += dt * direction * 500
@paddle.x = Phaser.Math.Clamp(@paddle.x, 55, size_x-55)
Paddle
follows the same pattern. Its direction
is sent to the update
method depending on which keys are pressed, and it moves left or right. Phaser.Math.Clamp
prevents it from going outside the canvas.
MainScene
class MainScene extends Phaser.Scene
preload: () ->
@load.image("star", "star.png")
@load.audio("coin", "coin.mp3")
create: () ->
@active = true
@paddle = new Paddle(@)
@ball = new Ball(@)
@bricks = []
for x from [1..11]
for y from [2..7]
@bricks.push(new Brick(@, x, y))
@emitter = new StarEmitter(@)
@coin = @sound.add("coin")
@coin.volume = 0.2
handle_brick_colission: (brick) ->
return if brick.destroyed
distance_x = Math.abs((brick.x - @ball.x) / (10 + brick.brick_x_size/2))
distance_y = Math.abs((brick.y - @ball.y) / (10 + brick.brick_y_size/2))
if distance_x <= 1.0 && distance_y <= 1.0
brick.destroy()
@emitter.burst_at(@ball.x, @ball.y)
@coin.play()
if distance_x < distance_y
@ball_bounce_y = true
else
@ball_bounce_x = true
is_game_won: () ->
@bricks.every((b) => b.destroyed)
update: (_, dts) ->
return unless @active
dt = dts / 1000.0
@ball.update(dt)
if @input.keyboard.addKey("RIGHT").isDown
@paddle.update(dt, 1)
else if @input.keyboard.addKey("LEFT").isDown
@paddle.update(dt, -1)
@ball_bounce_x = false
@ball_bounce_y = false
for brick from @bricks
@handle_brick_colission(brick)
@ball.dx = -@ball.dx if @ball_bounce_x
@ball.dy = -@ball.dy if @ball_bounce_y
paddle_distance = Math.abs(@paddle.x - @ball.x)
bottom_distance = size_y - @ball.y
if @ball.dy > 0
if bottom_distance <= 30 && paddle_distance <= 60
@ball.dy = -300
@ball.dx = 7 * (@ball.x - @paddle.x)
else if bottom_distance <= 10 && paddle_distance >= 60
@cameras.main.setBackgroundColor("#FAA")
@active = false
if @is_game_won()
@cameras.main.setBackgroundColor("#FFF")
@active = false
And finally the MainScene
. preload
, create
, and update
are Phaser methods. Everything else we just created ourselves.
I think everything should be fairly readable, as long as you remember that @foo
means this.foo
, so it's used for both instance variables and instance methods.
Is CoffeeScript dead?
While I feel nostalgia for it, the unfortunate answer is yes. I mentioned some historical background in previous episode, but ES6 adopted most of the features people used CoffeeScript for, and available tooling did not keep up with the times.
That's not to say the idea is dead. In particular Imba is a CoffeeScript-inspired language and framework that's absolutely worth checking out. It comes with an extremely expressive and performant framework. For some less extreme cases, Svelte, Vue, React, and so on all come with their own extended versions of JavaScript, so nobody really writes app in plain JavaScript anymore.
Results
Here's the results:
It's time to say goodbye to CoffeeScript, in the next episode we start another small project.
As usual, all the code for the episode is here.
Posted on October 3, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.