Intro
Now that we have setup a simple typescript game environment with cold reloading we are ready to start exploring different game design concepts using typescript code. The rest of the tutorials in this series will leverage typescript to demonstrate different game design concepts.
Whilst not totally necessary if you are not familiar with typescript the following tutorials will be easier to understand with some foundational typescript knowledge.
That's why today we will be looking at some specific common javascript and typescript examples and concepts that I frequently use when doing typescript game development.
Everything discussed in this tutorial can be tested in a typescript repl (an online typescript compiler).
If for whatever reason the repl isn't working. You can use the simple-typescript-canvas-game-project-setup as a base.
Lets jump in!
Primitive types
Typescript as the name suggests is a strongly typed language that builds on javascript. For anyone that has worked with a strongly typed language before typescript aims to bring to javascript all the benefits that types introduce to a language.
Typescript uses a : type
syntax to denote the type of an expression. Typescript offers type bindings for many common primitive types that exist in most strongly typed languages such as...
- string
- boolean
- number
Typescript primitive types are all lowercase so we use string
instead of String
. The String
keyword is the javascript
String
constructor and the lowercase string
is the typescript type binding.
Type bindings
When working with dependencies we often need to add the typescript
type bindings for those dependencies otherwise the typescript
compiler won't know how to infer the different libraries custom types.
An example of this using styled-components
(a css in js library) is below.
npm install --save styled-components
npm install --save @types/styled-components
The first line above saves the styled-components
dependency to our project.
The second line saves the type bindings required for styled-components
. Without these bindings we would receive compile errors when explicitly using styled-components
types.
Logical operators
&&: Used to signify logical AND
. true && false//false
, true&&true//true
||: Used to signify logical OR
. true || false//true
, false || false//false
!: Used to signify logical NOT
. !true//false
, !false//true
===: Used to test strict equality 11==='11'//false
==: Used to signify 11=='11'//true
In general it's best to use strict equality ===
and convert to the appropriate type before comparing.
Defining variables
In javascript we can use var
, let
and const
. var
has pretty much been superseded by const
and let
.
const
defines a constant that cannot be reassigned and let
can. A general rule of thumb is to prefer const
over let
unless you have to re-assign
a variables value.
const animal1: string = 'dog'
animal1 = 'cat' // Error
let animal2: string = 'dog'
animal2 = 'cat' // No error
Custom types
Typescript allows us to define custom types based on primitive types and even other custom types. Because of this we can build up a game domain type system like Lego blocks as shown below.
type Weapon = string
type Direction = 'left' | 'up' | 'right' | 'down'
type Position = {
x: number,
y: number
}
type PositionWithName = {name: string} & Position
type GameState = {
lives: number
score: number
playerName: string
playerPosition: Position
direction: Direction
spawnPosition: PositionWithName
}
const invalidDirection = 'forwards' // This will result in a compile error
const gameState: GameState = {
lives: 3,
score: 0,
playerName: 'Blake',
playerPosition: {
x: 0,
y: 0
},
direction: 'right',
spawnPosition: {
x: 100,
y: 100,
name: 'base'
}
}
The Weapon
type is a type alias
for the primitive string. We can now use the Weapon
type in place of the string
type. This can make code easier to understand if we have a function that takes a string
that represents a weapon we can use Weapon
instead for the argument.
Here we have defined a custom union type Direction
which can only take on the value of the 4 directions specified. We use the |
operator to create a union of the 4 different direction strings. Position
defined a x, y coordinate. We can use the object {}
key value syntax to describe custom types with nested fields.
The PositionWithName
demonstrates an intersection type using the &
operator which combines all the fields between the two types hence creating a new custom type with x
, y
and name
.
The invalidDirection
constant will result in a compile error as forwards
does not equal one of the possible Direction
types.
We define another custom type GameState
which has a direction field of type Direction
, playerPosition of type Position
and spawnPosition of type PositionWithName
.
Finally the const gameState
shows how we can declare a constant that has the GameState
type.
Functions
Typescript functions look similar to javascript functions except that the arguments and output have associated types. These types can be inferred or explicitly referenced.
const displayScore(score: number): string => {
return `Players score: ${score}`
}
displayScore(1) // 'Players score: 1'
The anatomy of the above function is described below.
In typescript
and javascript
functions are treated as first class citizens
which means we can assign them to variables and pass them around the same way we would pass around a primitive
type (boolean, string etc). This is why we are allowed to use the const
keyword to define our function.
displayScore
is our function's name, semantically we use camelCase for our function names.
Everything inside the ()
represents the arguments to our function. In this case our function takes one argument, score
of type number
.
The type after the :
, string
represents the return type of our function.
Everything inside the {}
is the body of our function. The code that is run when we invoke our function.
We are using JavaScript's string interpolation
that leverages back-ticks
``` to compute a dynamic variable in a string.
The return
keyword is used to make the function
return some output. The return
type should match our function's output type otherwise we will receive a compilation error.
We invoke our function using it's name and providing the required arguments, ie displayScore(1)
.
In the above example I have explicitly referenced both the argument types and output type of the function. If we leave these out typescript will infer the types and the code will still compile. However typescript won't always know the exact type we are intending to use so I prefer the explicit alternative syntax.
Classes
Classes can be used to group certain functionality together.
class Player {
name: string
age: number
constructor(name: string, age: number) {
this.name = name
this.age = age
}
static compareAge = (player1: Player, player2: Player): string => {
if (player1.age < player2.age) {
return `${player1.name} is younger than ${player2.name}`
} else if (player1.age === player2.age) {
return `${player1.name} is the same age as${player2.name}`
} else {
return `${player1.name} is older than ${player2.name}`
}
}
bio (): string {
return `${this.name}, age: ${this.age}`
}
}
const p1: Player = new Player('Tessy', 27)
const p2: Player = new Player('Lucy', 25)
console.log(p1.bio()) //Tessy, age: 27
console.log(Player.compareAge(p1, p2))//Tessy is older than Lucy
The Player
class above has two fields name
and age
that can be set via the constructor
when initiated as shown above.
In the above code p1
and p2
are separate instances of the Player
class and are instantiated via the constructor using the new
keyword.
The static
keyword in the compareAge
function means that compareAge
can be called on the Player
class and not on an instance of Player
The bio
function on the other hand can only be called on an instance of the Player
class.
If else and switch
if (health <= 0) {
triggerGameOver();
} else {
increaseScore();
}
switch(lightOn) {
case 'on':
return 'Light is on'
case 'off':
return 'Light is off'
case 'flashing':
return 'Light is flashing'
}
An if else statement allows us execute different code based on some condition. In the example above if health <= 0
gameOver
is triggered otherwise the score
is increased.
A switch
statement is another way to write conditional logic. If a case statement's condition is matched with the variable inside the switch statement the code inside the case statement is executed.
Ternary
const alive = (health > 0) ? true : false
The ternary operator ?
is a handy shorthand way to set a variable's value conditionally. In the example above alive
will be set to false
if the player's health is below 0 and true
if not. The same effect can be achieved via an if condition and a mutating variable but this is arguably cleaner code and these sort of conditional results are frequently present in games.
Imports exports
To use classes, functions or variables from another file they must be exported. The below code gives an example of how we can export the GameRuntime
class and LevelName
variable.
//Game.ts
export class GameRuntime {
}
const levelName = 'Webbed woods'
export const levelName
To import these values into another file the import
syntax can be used. Then the code can be referenced and called as shown below.
import {levelName, GameRuntime} from './Game'
console.log(levelName) //'Webbed woods'
Destructuring
Destructing provides an easy way to extract fields of an object as shown below.
type GameState = {
playerPos: Position
score: number
gameOver: boolean
}
const loop = (gameState: GameState) => {
const {playerPos, score, gameOver} = gameState
console.log(playerPos, score, gameOver)
}
loop({playerPos: {x: 1, y: 2}, score: 10, gameOver: false})//{x: 1, y: 2}, 10, false
Spread operator
The spread operator ...
allows us to easily add properties of one object to another object.
type GameState = {
playerPos: Position
score: number
gameOver: boolean
}
const gameState = {
playerPos: {x: 1, y: 2},
score: 10,
gameOver: false
}
const gameOverGameState = {
...gameState,
gameOver: false
}
console.log(gameOverGameState)//{playerPos: {x: 1, y: 2}, score: 10, gameOver: true}
In the example above we are spreading
the gameState onto a new gameOverGameState
which transfers all of the original properties of the current gameState, we then manually set the gameOver
field to be true
.
Summary
The above examples should give a good typescript foundation to help understand the rest of the tutorials in the series.