Decentraland Tutorial: A Simple Tower Defense Game

in #tutorial6 years ago (edited)

This is a tutorial on creating a simple Tower Defense Game in Decentraland. Creeps are making their way through your base. Stop as many as you can by springing traps at the right moment. This is multiplayer, who will win: Humans or the Creeps?

Full source code is available on GitHub.

If you are new to Decentraland development, you may want to start with our beginner tutorial, creating a Jukebox.

This tutorial was sponsored by Decentraland.


Setting Up the Environment

One time setup:

npm install -g decentraland


With a cmd prompt in the project's directory, run:

dcl init
  • Parcels: Select 4 parcels for this tutorial. Any 2x2 plot is fine for testing locally, for example:
42,42; 43,42; 42,43; 43,43
  • Scene Template: select Remote

For everything else, the defaults are fine.


In the server\ directory, run:

npm install
npm install -G nodemon

Note: nodemon is optional, however we are using it to auto-refresh the server when a build happens.


Modify server\package.json:

"scripts": {
  "build": "metaverse-compiler build.json",
  "watch": "metaverse-compiler build.json --watch",
  "start": "nodemon build/index.js"
},

Start the Scene

You'll want three different command prompts for this.


In the first command prompt, navigate to the server\ directory and run:

npm run watch

This will build your application. If any files are modified, it will rebuild automatically.


In the second command prompt, also in the the server\ directory, run:

npm start

This hosts your server for local testing, at ws://localhost:8087


And in the third prompt, navigate the the project directory and run:

dcl start

This starts the game and should open a new tab automatically to http://localhost:8000

Add Assets

Add the art for the game to the project's root directory.

You can download the models we've created or use your own of course.

Add a Random Path

We'll generate a path which always starts from the same location and then travels randomly until it reaches the other side.


Modify the state variable in server\state.ts to add a path:

import { Vector2Component } from 'metaverse-api'

let state: {
  path: Vector2Component[],
} = {
  path: [],
};


Modify server\RemoteScene.tsx to generate and render the path:

import * as DCL from 'metaverse-api'
import { Vector2Component } from 'metaverse-api'
import { setState, getState } from './State'

export default class CreepsScene extends DCL.ScriptableScene 
{
  sceneDidMount() 
  {
    if(getState().path.length == 0)
    {      
      this.newGame();
    }
  }

  newGame()
  {
    while(true)
    {
      try 
      {
        setState({
          path: generatePath(),
        });
  
        break;
      }
      catch {}
    }
  }

  renderTiles()
  {
    return getState().path.map((gridPosition) =>
    {
      return (
        <box position={{x: gridPosition.x, y: 0, z: gridPosition.y}} />
      );
    });
  }

  async render() 
  {
    return (
      <scene>
        {this.renderTiles()}
      </scene>
    );
  }
}

function getStartPosition(): Vector2Component
{
  return {x: 10, y: 1};
}

function isValidPosition(position: Vector2Component)
{
  return position.x >= 1 
    && position.x < 19 
    && position.y >= 1 
    && position.y < 19
    && (position.x < 18 || position.y < 18)
    && (position.x > 1 || position.y > 1);
}

function generatePath(): Vector2Component[]
{
  const path: Vector2Component[] = [];
  let position = getStartPosition();
  path.push(JSON.parse(JSON.stringify(position)));
  for(let i = 0; i < 2; i++)
  {
    position.y++;
    path.push(JSON.parse(JSON.stringify(position)));
  }

  let counter = 0;
  while(position.y < 18)
  {
    if(counter++ > 1000)
    {
      throw new Error("Invalid path, try again");
    }
    let nextPosition = {x: position.x, y: position.y};
    switch(Math.floor(Math.random() * 3))
    {
      case 0:
        nextPosition.x += 1;
        break;
      case 1:
        nextPosition.x -= 1;
        break;
      default:
        nextPosition.y += 1;
    }
    if(!isValidPosition(nextPosition) 
      || path.find((p) => p.x == nextPosition.x && p.y == nextPosition.y)
      || getNeighborCount(path, nextPosition) > 1)
    {
      continue;
    }
    position = nextPosition;
    path.push(JSON.parse(JSON.stringify(position)));
  }
  position.y++;
  path.push(JSON.parse(JSON.stringify(position)));
  return path;
}

function getNeighborCount(path: Vector2Component[], position: Vector2Component)
{
  const neighbors: {x: number, y: number}[] = [
    {x: position.x + 1, y: position.y},
    {x: position.x - 1, y: position.y},
    {x: position.x, y: position.y + 1},
    {x: position.x, y: position.y - 1},
  ];

  let count = 0;
  for(const neighbor of neighbors)
  {
    if(path.find((p) => p.x == neighbor.x && p.y == neighbor.y))
    {
      count++;
    }
  }

  return count;
}

Test: A random path should appear, rendered as white boxes (we'll style next).

Create a Component to Render Tiles

For this tutorial, we will be separating out the render logic for various components into their own file. This helps with readability as your app becomes more elaborate.

Components only include the render information. Any logic, including responding to events, is still owned by the main scene's class (server\RemoteScene.tsx).

Data, including state information, is communicated from the scene's class to the component by using properties.

Here's Decentraland's docs on components.


Add a material tag in `server\RemoteScene.tsx' defining the texture for the Tiles to use.

<scene>
  <material 
    id="floorTileMaterial" 
    albedoTexture="./assets/StoneFloor.png"
  />
  {this.renderTiles()}

The material is defined once and then leveraged for every individual tile. See Decentraland's doc on Materials.


Create a components directory and a file server\components\Tile.tsx:

import * as DCL from 'metaverse-api'
import { Vector2Component } from 'metaverse-api';

export interface ITileProps 
{
  gridPosition: Vector2Component,
}

export const Tile = (props: ITileProps) => 
{
  return (
    <plane
      position={{x: props.gridPosition.x, y: .01, z: props.gridPosition.y}}
      material="#floorTileMaterial"
      rotation={{x: 90, y: 0, z: 0}}
    />
  )
}


Change the renderTiles function in in server\RemoteScene.tsx' to leverage theTile` component we created:

import { Tile, ITileProps } from './components/Tile'
...

renderTiles()
{
  return getState().path.map((gridPosition) =>
  {
    const tileProps: ITileProps = {
      gridPosition
    };
    return Tile(tileProps);
  });
}

Test: A random path should appear as it did before, but now it's styled to create a stone path.

Static Scenery

Add a bit of static scenery to pretty the place up a bit:

  const endOfPath = getState().path[getState().path.length - 2];

  return (
    <scene>
      <material 
        id="floorTileMaterial" 
        albedoTexture="./assets/StoneFloor.png"
      />
      {this.renderTiles()}

      <plane
        position={{x: 10, y: 0, z: 10}}
        rotation={{x: 90, y: 0, z: 0}}
        scale={19.99}
        color="#666666"
      />        
      <gltf-model
        src="assets/Archway/StoneArchway.gltf"
        position={{x: 10, y: 0, z: 2}}
        rotation={{x: 0, y: 180, z: 0}}
        scale={{x: 1, y: 1, z: 1.5}}
      />
      <gltf-model
        src="assets/Archway/StoneArchway.gltf"
        position={{x: endOfPath.x, y: 0, z: endOfPath.y}}
        scale={{x: 1, y: 1, z: 1.5}}
      />
    </scene>
  );

Test: Confirm the position, scale, colors, etc for your scene.

Add Creeps

Creeps are the enemy for this game. They spawn periodically on one side of the map and then follow the path to make their way to the other side.


Create a component to render a creep at server\components\Creep.tsx:

import * as DCL from 'metaverse-api'
import { Vector2Component } from 'metaverse-api';

export interface ICreepProps 
{
  id: string,
  gridPosition: Vector2Component,
  isDead: boolean,
}

export const Creep = (props: ICreepProps) => 
{
  return (
    <gltf-model
      id={props.id}
      src="../assets/BlobMonster/BlobMonster.gltf" 
      position={{x: props.gridPosition.x, y: .1, z: props.gridPosition.y}}
      lookAt={{x: props.gridPosition.x, y: 0, z: props.gridPosition.y}}
      skeletalAnimation={[
        {
          clip: "Walking",
          playing: !props.isDead
        },
        {
          clip: "Dying",
          playing: props.isDead
        },
      ]}
      transition={{
        position: {
          duration: 500,
        },
        lookAt: {
          duration: 250,
        }
      }}
    />
  )
}


Update server\State.ts to add Creeps:

import { ICreepProps } from './components/Creep'

let state: {
  path: Vector2Component[],
  creeps: ICreepProps[],
} = {
  path: [],
  creeps: [],
};


Import the component and add a sleep method, a timer, and an object counter to the server\RemoteScene.tsx:

import { Creep, ICreepProps } from './components/Creep'

function sleep(ms: number): Promise<void> 
{
  return new Promise(resolve => setTimeout(resolve, ms));
} 
let spawnInterval: NodeJS.Timer;
let objectCounter = 0;


After the while loop in newGame, add:

clearInterval(spawnInterval);
spawnInterval = setInterval(() =>
{
  this.spawnCreep();
}, 3000 + Math.random() * 17000);


Add the spawnCreep and kill functions below:

async spawnCreep()
{
  for(const creep of getState().creeps)
  {
    if(JSON.stringify(creep.gridPosition) == JSON.stringify(getStartPosition()))
    {
      return;
    }
  }

  let creep: ICreepProps = {
    id: "Creep" + objectCounter++,
    gridPosition: getStartPosition(),
    isDead: false,
  };
  setState({creeps: [...getState().creeps, creep]});

  let pathIndex = 1;
  while(true)
  {
    if(creep.isDead)
    {
      return;
    }

    if(pathIndex >= getState().path.length)
    {
      this.kill(creep);
    }
    else
    {
      creep.gridPosition = getState().path[pathIndex];
      pathIndex++;        
      setState({creeps: getState().creeps});
    }

    await sleep(2000);
  }
}

async kill(creep: ICreepProps)
{
  creep.isDead = true;
  setState({creeps: getState().creeps});

  await sleep(2000);
  let creeps = getState().creeps.slice();
  creeps.splice(creeps.indexOf(creep), 1);
  setState({creeps});
}


Add creeps to render:

<scene>
...
  {this.renderCreeps()}
</scene>


And create a function renderCreeps:

renderCreeps()
{
  return getState().creeps.map((creep) =>
  {
    return Creep(creep);
  });
}

Test: Creeps should spawn and walk the path to the end, and then despawn. Note the first spawn may take up to 20 seconds.

Add Traps

The traps have three components. There are two levers and a set of spikes. When one lever has been pulled the other unlocks. Then when the second lever is pulled the spikes trigger for about a second, killing any creeps standing above.


Create a component for the traps at server\components\Trap.tsx:

import * as DCL from 'metaverse-api'
import { Vector2Component } from 'metaverse-api';

export const enum TrapState 
{
  Available,
  PreparedOne,
  PreparedBoth,
  Fired,
  NotAvailable,
}

export interface ITrapProps 
{
  id: string,
  gridPosition: Vector2Component,
  trapState: TrapState,
}

export const Trap = (props: ITrapProps) => 
{
  return (
    <entity>
      <gltf-model
        src="../assets/Lever/LeverBlue.gltf"
        id={props.id + "LeverLeft"}
        position={{x: props.gridPosition.x - 1, y: 0, z: props.gridPosition.y}}
        scale={.5}
        rotation={{x: 0, y: 90, z: 0}}
        skeletalAnimation={[
          {
            clip:"LeverOff", 
            playing: props.trapState <= TrapState.Available
          },
          {
            clip:"LeverOn", 
            playing: props.trapState == TrapState.PreparedOne
          },
          {
            clip:"LeverDeSpawn", 
            playing: props.trapState >= TrapState.Fired
          },
        ]}
      />
      <gltf-model
        id={props.id}
        src="../assets/SpikeTrap/SpikeTrap.gltf"
        position={{x: props.gridPosition.x, y: 0, z: props.gridPosition.y}}
        skeletalAnimation={[
          {
            clip:"SpikeUp", 
            playing: props.trapState == TrapState.Fired,
          },
          {
            clip:"Despawn", 
            playing: props.trapState == TrapState.NotAvailable
          },
        ]}
        scale={.5}
      />
      <gltf-model
        id={props.id + "LeverRight"}
        src="../assets/Lever/LeverRed.gltf"
        position={{x: props.gridPosition.x + 1, y: 0, z: props.gridPosition.y}}
        scale={.5}
        rotation={{x: 0, y: 90, z: 0}}
        skeletalAnimation={[
          {
            clip:"LeverOff", 
            playing: props.trapState <= TrapState.Available
          },
          {
            clip:"LeverOn", 
            playing: props.trapState == TrapState.PreparedBoth
          },
          {
            clip:"LeverDeSpawn", 
            playing: props.trapState >= TrapState.Fired
          },
        ]}
      /> 
    </entity>
  )
}


Add trap to server\State.ts:

import { ITrapProps} from './components/Trap'

let state: {
  ...
  traps: ITrapProps[],
} = {
  ...
  traps: [],
};


In server\RemoteScene.tsx add:

import { Trap, ITrapProps, TrapState } from './components/Trap'


Then inside the newGame function spawn two traps:

newGame()
  {
    while(true)
    {
      try 
      {
        ...
        this.spawnTrap();
        this.spawnTrap();
  
        break;


Add functions for spawning traps and responding to click events:

spawnTrap()
{
  let trap: ITrapProps = {
    id: "Trap" + objectCounter++,
    gridPosition: this.randomTrapPosition(),
    trapState: TrapState.Available,
  };
  setState({traps: [...getState().traps, trap]});
  this.subToTrap(trap);
}

subToTrap(trap: ITrapProps)
{
  this.eventSubscriber.on(trap.id + "LeverLeft_click", () =>
  {
    if(trap.trapState != TrapState.Available)
    {
      return;
    }
    trap.trapState = TrapState.PreparedOne;
    setState({traps: getState().traps});
  });

  this.eventSubscriber.on(trap.id + "LeverRight_click", async () =>
  {
    if(trap.trapState != TrapState.PreparedOne)
    {
      return;
    }
    trap.trapState = TrapState.PreparedBoth;
    setState({traps: getState().traps});

    await sleep(1000);
    trap.trapState = TrapState.Fired;
    setState({traps:  getState().traps});
    let counter = 0;

    while(true)
    {
      await sleep(100);
      
      for(const entity of getState().creeps)
      {
        if(JSON.stringify(entity.gridPosition) == JSON.stringify(trap.gridPosition) && !entity.isDead)
        {
          this.kill(entity);
        }
      }
      if(counter++ > 10)
      {
        trap.trapState = TrapState.NotAvailable;
        setState({traps: getState().traps});
        
        await sleep(1000);
        let traps = getState().traps.slice();
        traps.splice(traps.indexOf(trap), 1)
        setState({traps});
        
        await sleep(1000);
        this.spawnTrap(); 

        break;
      }
    };
  });
}

randomTrapPosition()
{
  let counter = 0;
  while(true)
  {
    if(counter++ > 1000)
    {
      throw new Error("Invalid path, try again");
    }

    const position = {x: Math.floor(Math.random() * 19), y: Math.floor(Math.random() * 19)};
    if(getState().path.find((p) => p.x == position.x && p.y == position.y)
      && !getState().path.find((p) => p.x == position.x - 1 && p.y == position.y)
      && !getState().path.find((p) => p.x == position.x + 1 && p.y == position.y)
      && position.y > 2
      && position.y < 18
      && position.x > 2
      && position.x < 18
      && !getState().traps.find((t) => JSON.stringify(position) == JSON.stringify(t.gridPosition)))
    {
      return position;  
    }
  } 
}


To ensure that someone joining a game-in-progress subscribes to events for the existing traps add the following to sceneDidMount. This will subscribe to events for all the existing traps:

sceneDidMount() 
{
  if(getState().path.length == 0)
  ...
  }
  else
  {
    for(const trap of getState().traps)
    {
      this.subToTrap(trap);
    }
  }


Add renderTraps:

<scene>
  ...
  {this.renderTraps()}
</scene>


And the function itself:

renderTraps()
{
  return getState().traps.map((trap) =>
  {
    return Trap(trap);
  });
}

Test: Pull both levers for a trap and test both a miss and a kill. Traps are single use, once fired they should despawn and then another should spawn in at a random location a second later.

Multiplayer Test: Open a second tab in your browser to simulate a second player. Try interacting with each, confirming the updates appear in both tabs.

Score

We'll add a scoreboard to the world, tracking progress of 'humans vs creeps'.


Create a server\components\ScoreBoard.tsx component:

import * as DCL from 'metaverse-api'

export interface IScoreBoardProps 
{
  humanScore: number,
  creepScore: number,
}

export const ScoreBoard = (props: IScoreBoardProps) => 
{
  return (
    <entity
        position={{x: 18.99, y: 0, z: 19}}
    >
      <gltf-model
        src="../assets/ScoreRock/ScoreRock.gltf"
      />
      <text 
        value={props.humanScore.toString()}
        position={{x: -.4, y: .35, z: -.38}}
        fontSize={200}
        color={props.humanScore > props.creepScore ? "#22ff22" : "#ffffff"}
      />
      <text 
        value="humans"
        position={{x: -.4, y: .1, z: -.38}}
        fontSize={50}
      />
      <text 
        value="vs"
        position={{x: 0, y: .35, z: -.38}}
        fontSize={100}
      />
      <text 
        value={props.creepScore.toString()}
        position={{x: .4, y: .35, z: -.38}}
        fontSize={200}
        color={props.creepScore > props.humanScore ? "#ff2222" : "#ffffff"}
      />
      <text 
        value="creeps"
        position={{x: .4, y: .1, z: -.38}}
        fontSize={50}
      />
    </entity>
  )
}


Update server\State.ts:

import { IScoreBoardProps } from './components/ScoreBoard'

let state: {
  ...
  score: IScoreBoardProps,
} = {
  ...
  score: {humanScore: 0, creepScore: 0},
};


In scene\RemoteScene.tsx:

import { ScoreBoard } from './components/ScoreBoard'


And add the score board to render:

<scene>
  ...
  {ScoreBoard(getState().score)}
</scene>

Test: The scoreboard should appear, 0 v 0.


Now let's update the score when a trap kills the creep:

if(JSON.stringify(entity.gridPosition) == JSON.stringify(trap.gridPosition) && !entity.isDead)
{
  this.kill(entity);

  let score = getState().score;
  score.humanScore++;
  setState({score});
}


And when the creep makes it to the end:

if(pathIndex >= getState().path.length)
{
  this.kill(creep);
  
  let score = getState().score;
  score.creepScore++;
  setState({score});
}

Test: Kill a creep or two and allow some to reach the end. You should see the scoreboard update appropriately.

New Game Button

Once the server starts, the world's state persists as people walk in and out of the world. We'll need a way to restart the game periodically, so we'll add a button.


Create a server\components\Button.tsx component:

import * as DCL from 'metaverse-api'
import { Vector3Component } from 'metaverse-api';

export enum ButtonState
{
  Normal,
  Pressed,
}

export interface IButtonProps 
{
  id: string,
  position: Vector3Component,
  state: ButtonState,
  label: string,
}

export const Button = (props: IButtonProps) => 
{
  let buttonZ = 0;
  if(props.state == ButtonState.Pressed)
  {
    buttonZ = .06;
  }
  return (
    <entity
      position={props.position}>
      <cylinder
        id={props.id}
        position={{x: 0, y: 0, z: buttonZ}}
        transition={{
          position: {
            duration: 100,
          },
        }} 
        rotation={{x: 90, y: 0, z: 0}}
        scale={{x: .05, y: .2, z: .05}}
        color="#990000" 
        />
      <text 
        hAlign="left"
        value={props.label} 
        position={{x: .4, y: 0, z: -.15}}
        scale={.6}
      />
    </entity>
  )
}


Update server\State.ts:

import { IButtonProps, ButtonState } from './components/Button'

let state: {
  ...
  startButton: IButtonProps,
} = {
  ...
  startButton: {
    id: "newGame",
    position: {x: 18.65, y: .7, z: 18.75},
    state: ButtonState.Normal,
    label: "New Game",
  }
};


In server\RemoteScene.tsx:

import { Button, ButtonState } from './components/Button'


Add the following to sceneDidMount:

this.eventSubscriber.on("newGame_click", async () =>
{
  let startButton = getState().startButton;
  startButton.state = ButtonState.Pressed;
  setState({startButton});
  await sleep(500);
  this.newGame();
  startButton.state = ButtonState.Normal;
  setState({startButton});
});


Modify the newGame function to kill existing creaps and clear the variables when the game restarts:

for(let creep of getState().creeps)
{
  creep.isDead = true;
}

while(true)
{
  try 
  {
    setState({
      path: generatePath(),
      creeps: [],
      traps: [],
      score: {humanScore: 0, creepScore: 0},
});


And update the render function:

<scene>
  {Button(getState().startButton)}
</scene>

Test: When you press the button creeps should despawn, a new random path appears, and the scores reset.



That’s it! This is a bare-bones implementation of a game, obviously it needs more in order to be compelling. Hope this helps you get started.

Some possible next steps:

  • Make the creeps spawn faster and walk faster as the game progresses, and/or randomize their movement.
  • Add health, instead of one-shot kills.
  • Change the lever interactions to require more than one person to be involved.
  • Track per-player scores (and maintain stats b/w games).
  • Add more weapon types, instead of just the trap.