Here we'll tackle issues I often see in students' code. Learning a few basic rules and patterns will help you create programs which are:
In addition, if you don't follow the suggestions in this guide, you run the risk of not scoring well on the following NEA mark scheme points:
Say you're creating a game which has an item the player can collect. Your poorly structured code may look like this:
let itemX = 10
let itemY = 100
let itemCollected = false
let itemValue = 5
These 4 different variables/identifiers are all related to - all belong to - the same item, and can easily be grouped together into one object for that item:
let item = { x: 10, y: 100, collected: false, value: 5 }
By encapsulating these properties together, you keep related data in one place, avoiding polluting the global scope and making it easier to pass the item around to functions. You can also extend them later by adding new properties.
Later, you decide instead of just 1 item, you'd like to hide 4 items around a level. Thankfully, you have at least followed the above advice (otherwise you'd have 16 identifiers in total - 4 for each of the 4 items) but you still have poorly structured code:
// define the items
let item1 = { x: 10, y: 100, collected: false, value: 5 }
let item2 = { x: 50, y: 150, collected: false, value: 2 }
let item3 = { x: 300, y: 150, collected: false, value: 8 }
let item4 = { x: 180, y: 100, collected: false, value: 4 }
// ...
// check if colliding with an item
if (detectCollision(player, item1)) {
item1.collected = true
player.score += item1.value
}
if (detectCollision(player, item2)) {
item2.collected = true
player.score += item2.value
}
if (detectCollision(player, item3)) {
item3.collected = true
player.score += item2.value
}
if (detectCollision(player, item4)) {
item4.collected = true
player.score += item4.value
}
Oh dear. The code may work, but you've got 4 almost identical if blocks and 4 identifiers (item1, item2, ...) to keep track of. Adding another item involves adding another identifier and another if block - and maybe you've got other code elsewhere that has multiple blocks or functions per item, and now you've got to duplicate and slightly change each of those too. It's very easy to accidentally make a mistake when copy + pasting blocks - can you see the single-character mistake I made above?
We're going to group these 4 similar items into a single array, reducing all the items and their properties down to just a single identifier. By using an array, adding a new item becomes a one-line data addition, without needing a new control-flow block:
// define the items
let items = [
{ x: 10, y: 100, collected: false, value: 5 },
{ x: 50, y: 150, collected: false, value: 2 },
{ x: 300, y: 150, collected: false, value: 8 },
{ x: 180, y: 100, collected: false, value: 4 }
]
// ...
// check if colliding with an item
for (let item of items) {
if (detectCollision(player, item)) {
item.collected = true
player.score += item.value
}
}
All the repetition? Gone! The copy + paste error I made earlier (item2.value instead of item3.value) is now impossible to make - all items use the same block of code.
But what if you wanted item1 to behave differently somehow to item2? Maybe you had this in your code before, which would have made the previous example's suggestion trickier to implement:
if (detectCollision(player, item1)) {
item1.collected = true
player.score += item1.value
getBigger(player) // mushroom power
}
if (detectCollision(player, item2)) {
item2.collected = true
player.score += item2.value
becomeInvincible(player) // star power
}
Ok, so item1 is like the mushroom in Mario and makes him bigger, while item2 is a star which makes him invincible. They're similar to each other in that they're both collectible items, but we clearly need to treat them differently because they behave differently.
To avoid the multiple very similar blocks of code, we can define a property on each item (such as type or power) and then select based on that:
let items = [
{ x: 10, y: 100, collected: false, value: 5, type: 'mushroom' },
{ x: 50, y: 150, collected: false, value: 2, type: 'star' },
// ...
]
for (let item of items) {
if (detectCollision(player, item)) {
item.collected = true
player.score += item.value
if (item.type === 'mushroom') getBigger(player)
else if (item.type === 'star') becomeInvincible(player)
// ... more else if lines
}
}
This is certainly an improvement. We've retained our single block of code to handle the player colliding with objects, yet we have a way to deal with items that behave differently from each other.
If we had 10 different item.types instead of 2, now we need 1 if and 9 else if lines. These chained conditionals are cumbersome, and can be replaced by directly looking up a property of an object:
let itemCollectedFunctions = {
mushroom: getBigger,
star: becomeInvincible
}
for (let item of items) {
if (detectCollision(player, item)) {
item.collected = true
player.score += item.value
itemCollectedFunctions[item.type]() // run the correct function for this item's type
}
}
Using a lookup object keeps the code scalable and clean when compared to the chain of conditionals we had before. Adding a new type of item requires only a new key-value pair added to the itemCollectedFunctions object.
What if we need multiple functions per item, one that needs to be run if Mario collects it and a different one if Yoshi collects it? We could start with this:
let itemTypes = {
mushroom: {
marioFunc: getBigger,
yoshiFunc: throwUp
},
star: {
marioFunc: becomeInvincible,
yoshiFunc: goCrazy
}
}
for (let item of items) {
if (detectCollision(player, item)) {
item.collected = true
player.score += item.value
if (player.type === 'mario') itemTypes[item.type].marioFunc(player)
else if (player.type === 'yoshi') itemTypes[item.type].yoshiFunc(player)
}
}
But now conditionals have appeared in our collision resolution block again. In addition, we may way to define our things which are different between different types of items, such as the image used to render it and the item's size:
let itemTypes = {
mushroom: {
image: 'mushroom.png',
size: 50,
collectedFuncs: {
mario: getBigger,
yoshi: throwUp
}
},
star: {
image: 'star.svg',
size: 40,
collectedFuncs: {
mario: becomeInvincible,
yoshi: goCrazy
}
}
}
for (let item of items) {
if (detectCollision(player, item)) {
item.collected = true
player.score += item.value
itemTypes[item.type].funcs[player.type](player)
}
}
... much better! We've now fully separated our data from our logic. If we've done that, it's usually a good indication that we've structured our code well.
Let's say we have this:
let itemType = {
mushroom: {
collectedFuncs: {
mario: getBigger,
yoshi: throwUp
}
},
star: {
collectedFuncs: {
mario: becomeInvincible,
yoshi: becomeInvincible
}
}
}
function becomeInvincible (player, duration) {
// ...
}
function getBigger (player) {
// ...
}
for (let item of items) {
if (detectCollision(player, item)) {
itemTypes[item.type].funcs[player.type](player)
}
}
... and we want Mario to become invincible for 30s when collecting a star, but Yoshi only for 10s. Here are the difficulties with itemTypes[item.type].funcs[player.type](player):
player, and lacks a 2nd one for durationgetBigger() only needs 1 argument and doesn't need a durationthrowUp() might need a different 2nd argument, and maybe even more argumentsIt's tempting to add conditionals back in, but we must resist!
I'm going to present 2 options to help with this.
Option 1:
let itemTypes = {
mushroom: {
collectedFuncs: {
mario: getBigger,
yoshi: (player) => throwUp(player, 'green', 5)
}
},
star: {
collectedFuncs: {
mario: (player) => becomeInvincible(player, 30),
yoshi: (player) => becomeInvincible(player, 10)
}
}
}
In this option, we've created anonymous arrow function wrappers in order to customise the arguments sent to the original functions. With yoshi: (player) => becomeInvincible(player, 10) when this function is run it will in turn call becomeInvincible() with 10s set as the 2nd argument.
Option 2:
let playerTypes = {
mario: {
invincibilityDuration: 30,
// ... other properties
},
yoshi: {
invincibilityDuration: 10,
// ... other properties
}
}
function becomeInvincible (player) {
let duration = playerTypes[player.type].invincibilityDuration
// ...
}
In this option, we've given each player type some settings/properties, and we just access those within the function itself.
I tend to prefer Option 1 as it keeps the behaviour driven by data attached to the item rather than the player. It's also allowed becomeInvincible to be a pure function, while Option 2 requires it to read from global state (playerTypes). Items remain self-contained - adding a new item is just a matter of defining its handler functions.
Here's a simple game with poorly structured code. Your job is to:
<!doctype html>
<title>Dirty -> clean</title>
<canvas id="canvas" width="400" height="400" style="background: black;"></canvas>
<script>
let ctx = canvas.getContext('2d')
ctx.font = '20px monospace'
let frame = 0
// mushroom
let item1X = 110
let item1Y = 150
let item1W = 20
let item1H = 20
let item1Collected = false
let item1Value = 3
// star
let item2X = 250
let item2Y = 100
let item2W = 20
let item2H = 20
let item2Collected = false
let item2Value = 5
// mario
let player1X = 100
let player1Y = 50
let player1W = 30
let player1H = 30
let player1Score = 0
let player1IsInvincible = false
// yoshi
let player2X = 200
let player2Y = 50
let player2W = 30
let player2H = 30
let player2Score = 0
let player2IsInvincible = false
let keyPlayer1Up = false
let keyPlayer1Left = false
let keyPlayer1Down = false
let keyPlayer1Right = false
let keyPlayer2Up = false
let keyPlayer2Left = false
let keyPlayer2Down = false
let keyPlayer2Right = false
setInterval(gameLoop, 1000/60)
function detectCollision (aX, aY, aW, aH, bX, bY, bW, bH) {
return (
aX < bX + bW && aX + aW > bX &&
aY < bY + bH && aY + aH > bY
)
}
// mushroom
function item1Check () {
if (!item1Collected) {
// player 1
if (detectCollision(player1X, player1Y, player1W, player1H, item1X, item1Y, item1W, item1H)) {
console.log('yes')
item1Collected = true
player1Score += item1Value
// get bigger
player1H += 10
}
// player 2
if (detectCollision(player2X, player2Y, player2W, player2H, item1X, item1Y, item1W, item1H)) {
item1Collected = true
player2Score += item1Value
// get bigger
player2H += 10
}
}
}
// star
function item2Check () {
if (!item2Collected) {
// player 1
if (detectCollision(player1X, player1Y, player1W, player1H, item2X, item2Y, item2W, item2H)) {
item2Collected = true
player1Score += item2Value
// become invincible
player1IsInvincible = true
setTimeout(() => player1IsInvincible = false, 30_000) // runs out after 30s
}
// player 2
if (detectCollision(player2X, player2Y, player2W, player2H, item2X, item2Y, item2W, item2H)) {
item2Collected = true
player1Score += item2Value
// become invincible
player2IsInvincible = true
setTimeout(() => player2IsInvincible = false, 10_000) // runs out after 10s
}
}
}
function gameLoop () {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// update state
frame++
player1Move()
player2Move()
item1Check()
item2Check()
// draw state
item1Draw()
item2Draw()
player1Draw()
player2Draw()
player1ScoreDraw()
player2ScoreDraw()
}
function player1Move () {
if (keyPlayer1Up) player1Y -= 1
if (keyPlayer1Left) player1X -= 1
if (keyPlayer1Down) player1Y += 1
if (keyPlayer1Right) player1X += 1
}
function player2Move () {
if (keyPlayer2Up) player2Y -= 1
if (keyPlayer2Left) player2X -= 1
if (keyPlayer2Down) player2Y += 1
if (keyPlayer2Right) player2X += 1
}
function player1Draw () {
ctx.fillStyle = 'red'
if (player1IsInvincible && !(frame % 8)) ctx.fillStyle = 'white'
ctx.fillRect(player1X, player1Y, player1W, player1H)
}
function player2Draw () {
ctx.fillStyle = 'green'
if (player2IsInvincible && !(frame % 8)) ctx.fillStyle = 'white'
ctx.fillRect(player2X, player2Y, player2W, player2H)
}
function player1ScoreDraw () {
ctx.fillStyle = 'red'
ctx.fillText(player1Score, 50, 20)
}
function player2ScoreDraw() {
ctx.fillStyle = 'green'
ctx.fillText(player2Score, canvas.width - 100, 20)
}
// mushroom
function item1Draw () {
if (!item1Collected) {
ctx.fillStyle = 'orange'
ctx.fillRect(item1X, item1Y, item1W, item1H)
}
}
// star
function item2Draw () {
if (!item2Collected) {
ctx.fillStyle = 'yellow'
ctx.fillRect(item2X, item2Y, item2W, item2H)
}
}
onkeydown = (e) => {
if (e.key === 'w') keyPlayer1Up = true
if (e.key === 'a') keyPlayer1Left = true
if (e.key === 's') keyPlayer1Down = true
if (e.key === 'd') keyPlayer1Right = true
if (e.key === 'ArrowUp') keyPlayer2Up = true
if (e.key === 'ArrowLeft') keyPlayer2Left = true
if (e.key === 'ArrowDown') keyPlayer2Down = true
if (e.key === 'ArrowRight') keyPlayer2Right = true
}
onkeyup = (e) => {
if (e.key === 'w') keyPlayer1Up = false
if (e.key === 'a') keyPlayer1Left = false
if (e.key === 's') keyPlayer1Down = false
if (e.key === 'd') keyPlayer1Right = false
if (e.key === 'ArrowUp') keyPlayer2Up = false
if (e.key === 'ArrowLeft') keyPlayer2Left = false
if (e.key === 'ArrowDown') keyPlayer2Down = false
if (e.key === 'ArrowRight') keyPlayer2Right = false
}
</script>