Decentraland Tutorial: A Simple Tower Defense Game

View this thread on: d.buzz | hive.blog | peakd.com | ecency.com
·@hardlydifficult·
0.000 HBD
Decentraland Tutorial: A Simple Tower Defense Game
<iframe width="560" height="315" src="https://www.youtube.com/embed/XPcMaGtX37k" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>

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](https://github.com/hardlydifficult/DecentralandCreeps).

If you are new to Decentraland development, you may want to start with our [beginner tutorial, creating a Jukebox](https://steemit.com/tutorial/@hardlydifficult/decentraland-tutorial-creating-a-music-jukebox).

This tutorial was sponsored by Decentraland.

<hr>

##  Setting Up the Environment 

One time setup:

 - [Node.js](https://nodejs.org/en/download/) 
 - [Python](https://www.python.org/downloads/)

```
npm install -g decentraland
```

<br>

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.

<br>

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.

<br>

Modify `server\package.json`:

```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.

<br>

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.

<br>

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

<br>

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](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](https://github.com/hardlydifficult/DecentralandCreeps/raw/master/assets.zip) 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.

<br>

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

```typescript
import { Vector2Component } from 'metaverse-api'

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

<br>

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

```typescript
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](https://docs.decentraland.org/sdk-reference/scene-state/#reference-the-state-from-a-child-object).

<br>


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

```typescript
<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](https://docs.decentraland.org/sdk-reference/scene-content-guide/#materials).

<br>

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

```typescript
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}}
    />
  )
}
```

<br>


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

```typescript
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:

```typescript
  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.

<br>

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

```typescript
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,
        }
      }}
    />
  )
}
```

<br>

Update `server\State.ts` to add Creeps:

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

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

<br>

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

```typescript
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;
```

<br>

After the while loop in `newGame`, add:

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

<br>

Add the `spawnCreep` and `kill` functions below:

```typescript
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});
}
```

<br>

Add creeps to `render`:

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

<br>

And create a function `renderCreeps`:

```typescript
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.

<br>

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

```typescript
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>
  )
}
```

<br>

Add trap to `server\State.ts`:

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

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

<br>

In `server\RemoteScene.tsx` add:

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

<br>

Then inside the `newGame` function spawn two traps:

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

<br>

Add functions for spawning traps and responding to click events:

```typescript
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;  
    }
  } 
}
```

<br>

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:

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

<br>

Add `renderTraps`:

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

<br>

And the function itself:

```typescript
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'.

<br>

Create a `server\components\ScoreBoard.tsx` component:

```typescript
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>
  )
}
```

<br>

Update `server\State.ts`:

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

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

<br>

In `scene\RemoteScene.tsx`:

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

<br>

And add the score board to `render`:

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

**Test**: The scoreboard should appear, `0 v 0`.

<br>

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

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

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

<br>

And when the creep makes it to the end:

```typescript
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.

<br>

Create a `server\components\Button.tsx` component:

```typescript
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>
  )
}
```

<br>

Update `server\State.ts`:

```typescript
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",
  }
};
```

<br>

In `server\RemoteScene.tsx`:

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

<br>

Add the following to `sceneDidMount`:

```typescript
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});
});
```

<br>

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

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

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

<br>

And update the `render` function:

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

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

<br>

<hr>


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.
👍 , , , , , , , , , , , , , ,