The purpose of this repo is to serve as an example of how to port a redux game to a Decentraland scene.
You can clone this repo and follow the installation guide to test it, or you can follow the tutorial and build it yourself.
The original game is @zackpudil's redux-chess-app, I just forked it and ported it to Decentraland.
You will need to have Decentraland's SDK installed. If you haven't installed it yet, run this command:
npm install -g decentraland
Then clone the repo from github:
git clone https://github.com/cazala/decentraland-redux-chess-app.git
cd decentraland-redux-chess-app
Finally, run these commands from the repo's folder to install the dependencies and build it:
npm install
cd scene
npm install
cd server
npm install
npm run build
Once you've installed everything, you can see the scene in action. Since this is a remote scene, a single scene state runs on a separate server. You must first start this server by running the following command from the decentraland-redux-chess-app/scene/server
directory:
npm start
Let this terminal window keep executing so that the server keeps running. Open a second terminal window and go to the decentraland-redux-chess-app/scene
directory and start the SDK preview:
dcl preview
A new tab in your browser should open with our chess scene.
If you want to play the original 2D redux-based chess game that we worked with to build this scene, go to the root directory (decentraland-redux-chess-app
) and run this:
npm run server
Then open a new browser tab and point it to http://localhost:8080/
.
The idea of this tutorial is to show you how to take an existing redux game and port it to Decentraland. The game I chose is @zackpudil's redux-chess-app, which looks like this:
Prerequisites
This tutorial assumes you are on a unix machine (or at least using a unix-like terminal), with git
, npm
and Decentraland's SDK installed. If you haven't installed them yet, follow these steps:
-
Install git.
-
Install nvm and then run
nvm use stable
to installnpm
. -
Once you have
npm
installed, runnpm install -g decentraland
to install Decentraland's SDK.
Fast-Forward
Each step in this tutorial is represented on its own release on this repo, so you can fast-foward to any step using git clone
like this:
git clone --branch step-0 https://github.com/cazala/decentraland-redux-chess-app.git --depth=1
Just replace the step-0
with the step you want to jump to.
The fist step is to clone the repo from the original redux game that we want to work with:
git clone https://github.com/zackpudil/redux-chess-app
cd redux-chess-app
Then install its dependencies by running the following command from the repo's folder:
npm install
Now we can initialize our decentraland scene in a folder inside this same repo. We'll create a scene
directory and initialize the SDK in it.
mkdir scene
cd scene
dcl init
The SDK will prompt you with a few questions in order to initialize the scene. It will ask you which type of scene is this going to be, make sure you select Remote (the third option).
It will also ask you which parcel(s) comprise this scene. If you don't have any parcels that's okay, you can enter any coordinates like 0,0
since we are running this scene locally.
Now that we have initialized our scene, we are (almost) ready to start porting the game.
First, we need to replace the aliased imports with relative ones, because the former ones are not compatible with Decentraland's SDK, like this:
-import { groupMovesByColor } from '~/modules/game/selectors';
+import { groupMovesByColor } from '../modules/game/selectors';
If you don't want to deal with all those replaces and just want to jump into the fun stuff, you can skip this step running the fast-forward command from below:
Diff: to see the full diff of changes for this step check this commit.
Fast-Forward: to jump to the end of this step, run git clone --branch step-0 https://github.com/cazala/decentraland-redux-chess-app.git --depth=1
Now we can start porting the chess game to our scene. Inside the scene
directory that we created on the previous step, there's a server
folder, let's move into it:
cd server
We can start by deleting the State.ts
file, since the game state will now reside in the redux
store.
rm State.ts
Now let's add a Store.ts
file where we will import the redux
store, initialize it, and then export it with all the actions that we will use from the scene:
// scene/server/Store.ts
const store = require('../../../src/store').default
const {
initSquares,
squareClick
} = require('../../../src/modules/squares/actions')
store.dispatch(initSquares())
export { initSquares, squareClick }
export default store
Then we need to modify ConectedClient.ts
so all the clients get updated when there's a change in the redux store:
import RemoteScene from './RemoteScene'
+import store from './Store'
export const connectedClients: Set<RemoteScene> = new Set()
-export function updateAll() {
+store.subscribe(() => {
connectedClients.forEach(function each(client) {
client.forceUpdate()
})
-}
+})
We can now create an assets
folder inside our scene
directory to place the models for all the pieces:
cd ..
mkdir assets
You can get the assets from here.
Once all the models have been placed in that folder, we can go back to the server:
cd ..
cd server
Finally we need to modify RemoteScene.tsx
so that it imports the redux store, gets its state, and uses it to render the board and all the pieces in the scene.
So let's start by removing the import of State.ts
and replace it with Store.ts
// scene/server/RemoteScene.tsx
-import { setState, getState } from './State'
+import store, { squareClick } from './Store'
Now let's replace the render
function with the following
async render() {
return <scene>{this.renderBoard()}</scene>
}
This will render the output of the renderBoard
method. Let's define this method:
renderBoard() {
return (
<entity
position={{
x: 1.5,
y: 0,
z: 1.5
}}
>
{store.getState().squares.map(this.renderSquare)}
</entity>
)
}
That will render a base entity and position it on the scene, then it will get the squares
array from the redux store's state and run a map
operation over it, running the renderSquare
method for each square.
This method will receive each square
and its index (which goes from 0 to 63). Each square
contains the state for a particular tile on the board. The state indicates:
- If there's a chess piece on the tile, and which
- If the tile is selected
- If the tile is highlighted
- If the tile is in check
We can then use this information to render the board on the scene.
The first thing we need to do is convert the 1-dimentional index (0 to 63) into 3D coords (x, y, z).
renderSquare(square: any, index: number) {
const x = 7 - (index % 8)
const y = 0
const z = Math.floor(index / 8)
const position = { x, y, z }
The second thing we have to do is figure out the color of the tile, which will alternate bewtween black and white, unless the tile is in a special state (highlighted, selected or in check):
let color = (x + z) % 2 === 0 ? '#FFFFFF' : '#000000'
if (square.selected) {
color = '#7FFF00'
} else if (square.highlighted) {
color = square.pieceId !== '_' ? '#FF0000' : '#FFFF00'
} else if (square.check) {
color = '#FFA500'
}
- If the square is selected we make it green.
- If the square is highlighted we then compare the
pieceId
with the string'_'
(which means there's no chess piece on that square).- If there are no pieces on the square, the player can move to it, so we make it yellow.
- If there's a piece on the square, the player can eat it, so we make it red.
- If the square is in check we will make it orange.
Before rendering the actual tile, let's add a modelsById
map at the top of our file that maps each pieceId
to the corresponding model, like this:
const modelsById: { [key: string]: string } = {
B: 'assets/LP Bishop_White.gltf',
b: 'assets/LP Bishop_Black.gltf',
K: 'assets/LP King_White.gltf',
k: 'assets/LP King_Black.gltf',
N: 'assets/LP Knight_White.gltf',
n: 'assets/LP Knight_Black.gltf',
P: 'assets/LP Pawn_White.gltf',
p: 'assets/LP Pawn_Black.gltf',
Q: 'assets/LP Queen_White.gltf',
q: 'assets/LP Queen_Black.gltf',
R: 'assets/LP Rook_White.gltf',
r: 'assets/LP Rook_Black.gltf'
}
Now we can write the last part of the renderSquare
method:
const tileSize = { x: 1, y: 0.1, z: 1 }
return (
<entity position={position}>
<box color={color} scale={tileSize} id={`${square.id}-tile`} />
{square.pieceId in modelsById ? (
<gltf-model src={modelsById[square.pieceId]} id={`${square.id}-piece`} />
) : null}
</entity>
)
For each square, we are rendering an <entity>
as a wrapper in its corresponding position, inside this entity we render a <box>
that represents the tile, and if the square.pieceId
is inside our modelsById
map, we also render a <glft-model>
that represents the piece.
We add id
s to both the <box>
and the <gltf-model>
. In order to listen for a click event on an entity, the entity must have its own id. To listen for events we need to modify the eventSubscriber
that was created as part of the default scene, let's start by removing the call to setState
:
sceneDidMount() {
this.eventSubscriber.on('click', event => {
- setState({ isDoorClosed: !getState().isDoorClosed })
})
}
We can get the elementId
from that event
object that receives the listener:
const { elementId } = event.data
if (elementId != null) {
// dispatch redux action
}
That elementId
can have either a -tile
suffix or a -piece
suffix. We need to get the squareId
from that elementId
by removing the suffix. Let's add this helper function at the top of the file:
const getSquareId = (elementId: string) => elementId.split('-')[0]
This expression takes the string from elementId
, splits it in two at the -
character, and returns the first part. Let's use it to get the squareId
and dispatch a squareClick
action:
sceneDidMount() {
this.eventSubscriber.on('click', event => {
const { elementId } = event.data
if (elementId != null) {
const squareId = getSquareId(elementId)
const square = store
.getState()
.squares.find((square: any) => square.id === squareId)
store.dispatch(squareClick(square.id, square.pieceId, square.color))
}
})
}
Now we are all set!
Let's run a build for the server and test it out:
npm run build
npm start
That will start our server, which we must keep running. On a second terminal window, go to the scene
directory and start the SDK preview:
cd ..
dcl preview
On a broswer, open http://localhost:8000
and you should see the board rendered on the screen, you should be able to click on the piece to move them, in the same way as in the original game!
Diff: to see the full diff of changes for this step check this commit.
Fast-Forward: to jump to the end of this step run git clone --branch step-1 https://github.com/cazala/decentraland-redux-chess-app.git --depth=1
It's no fun to play against yourself, so in this last step we will modify the server so that it:
- Keeps track of which clients are playing
- Keeps track of the status of the match
- Only lets the clients who are currently playing move the pieces
- Only let clients move their own pieces
So the first thing we will do is add a match
module to the redux game, under src/modules/match
.
In that new module, let's add three actions: registerPlayer
, unregisterPlayer
and checkmate
:
// src/modules/match/actions.js
export const REGISTER_PLAYER = 'chess/match/register_player'
export const UNREGISTER_PLAYER = 'chess/match/unregister_player'
export const CHECKMATE = 'chess/match/checkmate'
export const registerPlayer = (playerId, isWhite) => ({
type: REGISTER_PLAYER,
playerId,
isWhite
})
export const unregisterPlayer = playerId => ({
type: UNREGISTER_PLAYER,
playerId
})
export const checkmate = () => ({
type: CHECKMATE
})
And let's add a reducer to handle those actions:
// src/modules/match/reducer.js
import { INIT_SQUARES } from '../squares/actions'
import { REGISTER_PLAYER, UNREGISTER_PLAYER, CHECKMATE } from './actions'
const STATUS = {
idle: 'idle',
started: 'started',
checkmate: 'checkmate'
}
const initialState = {
playerWhite: null,
playerBlack: null,
status: STATUS.idle
}
export default (state = initialState, action) => {
switch (action.type) {
case INIT_SQUARES:
return initialState
case REGISTER_PLAYER: {
// ...
}
case UNREGISTER_PLAYER: {
// ...
}
case CHECKMATE: {
// ...
}
default:
return state
}
}
In this part of the state we will keep track of the game status
, which can be idle
, started
or checkmate
. We will also store the id
of the clients that are using either the black pieces or the white ones. Below we will see how to handle each of the possible actions. We'll start by the action of resetting to the initial state, which is already defined:
case INIT_SQUARES:
return initialState
The INIT_SQUARES
action is quite simple, it just resets the state to its default values.
Let's look at the action of registering a new player into the game:
case REGISTER_PLAYER: {
let { playerWhite, playerBlack, status } = state
if (playerWhite && playerBlack) {
return state // both players already registered
}
if (playerWhite === action.playerId || playerBlack === action.playerId) {
return state // this player is already registered
}
// register the player
if (action.isWhite) {
playerWhite = action.playerId
} else {
playerBlack = action.playerId
}
if (playerWhite && playerBlack) {
status = STATUS.started // if both players are registered the game can start
}
return {
playerWhite: playerWhite,
playerBlack: playerBlack,
status: status
}
}
If both players are already registered, we will ignore this action. If this player id is already registered as the white or the black player, we will also ignore this action.
We then check the value of action.isWhite
to know with which color to register the new client. Once there are two clients registered and occupying the roles of the white and the black players, we change the status
to started
.
And that's all we have to do to register the players for the game. Now let's see how to unregister them:
case UNREGISTER_PLAYER: {
const { playerWhite, playerBlack } = state
if (playerWhite === action.playerId || playerBlack === action.playerId) {
return initialState // one of the players playing left, so we reset the game
}
}
We just check if the client that wants to unregister is one of the two active players, and if so, we reset the entire game to the idle
state.
Finally, we handle the "checkmate" action:
case CHECKMATE: {
if (state.status === STATUS.started) {
return Object.assign({}, state, {
status: STATUS.checkmate
})
}
return state
}
So now our server is capable of registering which players are currently playing. We can plug this new reducer into our store (this is src/store
):
import squares from './modules/squares/reducer'
import pieces from './modules/pieces/reducer'
import game from './modules/game/reducer'
+import match from './modules/match/reducer'
const rootReducer = combineReducers({
squares: squares, // main ui slice of the state.
takenPieces: pieces, // taken pieces list.
game: game, // move recording.
+ match: match // game match status
})
The last thing we need to do on the Redux side is to modify the Analysis Middleware to handle the "checkmate" action:
// src/modules/middleware/analysis-middleware.js
import {
addPiece,
removePiece,
- checkSquare
+ checkSquare,
+ initSquares
} from '../../modules/squares/actions'
import {
wasKingCastle,
wasQueenCastle,
getSquaresOfPiece
} from '../../chess/analysis'
import engine, { isKingInCheck } from '../../chess/engine'
+import { checkmate } from '../match/actions'
export default store => next => action => {
switch (action.type) {
// ...
if (isKingInCheck(board, !isWhite)) {
next(checkSquare(getSquaresOfPiece(isWhite ? 'k' : 'K', board)[0]))
next(checkSquare(action.toSquare))
+ const squares = store.getState().squares
+ const squaresWithPiecesFromPlayerInCheck = squares.filter(
+ square =>
+ square.pieceId !== '_' &&
+ (isWhite ? square.color === 'piece_black' : 'piece_white')
+ )
+ const amountOfValidMoves = squaresWithPiecesFromPlayerInCheck.reduce(
+ (moves, square) =>
+ moves + engine(squares)(square.pieceId)(square.id).length,
+ 0
+ )
+ if (amountOfValidMoves === 0) {
+ if (store.getState().match.status === 'started') {
+ next(checkmate())
+ setTimeout(() => next(initSquares()), 10000)
+ }
+ }
}
Basically, what our code is doing here is, after detecting that a king is in check, we filter all squares with pieces from the player who's in check:
const squares = store.getState().squares
const squaresWithPiecesFromPlayerInCheck = squares.filter(
square =>
square.pieceId !== '_' &&
(isWhite ? square.color === 'piece_black' : 'piece_white')
)
Then we run the game engine on all those squares, and compute the amount of valid moves that the player has left:
const amountOfValidMoves = squaresWithPiecesFromPlayerInCheck.reduce(
(moves, square) => moves + engine(squares)(square.pieceId)(square.id).length,
0
)
If the amount of valid moves is 0, then we know the player is in checkmate, so we can dispatch a checkmate()
action, followed by a game reset 10 seconds later:
if (amountOfValidMoves === 0) {
if (store.getState().match.status === 'started') {
next(checkmate())
setTimeout(() => next(initSquares()), 10000)
}
}
And that's all we had to do on the Redux side! Let's go back to the server in (scene/server
) and modify the Server.ts
file so that it dispatches an unregisterPlayer()
action when a client disconects:
// scene/server/Server.ts
import { connectedClients } from './ConnectedClients'
import { WebSocketTransport } from 'metaverse-api'
import RemoteScene from './RemoteScene'
+import store, { unregisterPlayer } from './Store'
// ...
wss.on('connection', function connection(ws, req) {
connectedClients.add(client)
- ws.on('close', () => connectedClients.delete(client))
+ ws.on('close', () => {
+ connectedClients.delete(client)
+ store.dispatch(unregisterPlayer(client.id))
+ })
Finally, we need to modify RemoteScene.tsx
to handle both game states (game started
and idle
), allow user registration, and prevent users from playing when it's not their turn.
The first thing we need to do is to add an id
to each client (this will be used as the player id), we will genereate this id randomly:
export default class Chess extends ScriptableScene {
+ public id: number = Math.random()
Now let's replace the render
method with the following:
async render() {
const status = store.getState().match.status
return (
<scene>
{status === 'idle' ? this.renderIdle() : this.renderBoard()}
</scene>
)
}
That will read the game status
from the Redux store and render either the idle
state or the board.
We can now define what we render when the scene is idle in renderIdle()
. We will render a white queen and a black queen on the scene, and a text that reads "Choose your color". We will assign the ids register-white
and register-black
to the queens so we can listen to click events on them. When any of the players is registered, we will render that queen in the position y: 1
(elevated up in the air) to indicate that it has already been selected by another player:
renderIdle() {
const { playerWhite, playerBlack } = store.getState().match
return (
<entity>
<gltf-model
src={modelsById['Q']}
id="register-white"
position={{ x: 3.5, y: playerWhite ? 1 : 0, z: 5 }}
/>
<gltf-model
src={modelsById['q']}
id="register-black"
position={{ x: 6.5, y: playerBlack ? 1 : 0, z: 5 }}
/>
<text
value="Choose your color"
color="#000000"
position={{ x: 5, y: 2, z: 5 }}
width={3}
billboard={7}
/>
</entity>
)
}
Now we only need to modify the eventSubscriber
to dispatch the register actions and to prevent dispatching click actions when it's not the player's turn.
First we will read what turn is it (black or white) and both player ids from the state:
this.eventSubscriber.on('click', event => {
const { elementId } = event.data
const state = store.getState()
const {
game: { whiteTurn },
match: { playerWhite, playerBlack }
} = state
Now we can check if the click has been done on any of the two "register" queens and, if so, dispatch a registerPlayer
action. The first player who registers will also dispatch an initSquares
to reset the board from any previous games:
if (elementId === 'register-white') {
if (!playerBlack) {
store.dispatch(initSquares()) // let the first player who registers init the board
}
store.dispatch(registerPlayer(this.id, true))
} else if (elementId === 'register-black') {
if (!playerWhite) {
store.dispatch(initSquares()) // let the first player who registers init the board
}
store.dispatch(registerPlayer(this.id, false))
}
Finally if the elementId
doesn't match any of the "register" queens, we can dispatch a squareClick
event, but only after checking that it is this client's turn:
} else if (elementId != null) {
// players can click squares only on their turn
if (whiteTurn && this.id !== playerWhite) return
if (!whiteTurn && this.id !== playerBlack) return
// click on square
const squareId = getSquareId(elementId)
const square = state.squares.find(
(square: any) => square.id === squareId
)
store.dispatch(squareClick(square.id, square.pieceId, square.color))
}
Finally let's add a <text>
that reads who's turn is it, and announces when there's a "checkmate".
For this we will add a renderMessage
method and call it from renderBoard
:
renderBoard() {
return (
<entity
position={{
x: 1.5,
y: 0,
z: 1.5
}}
>
{store.getState().squares.map(this.renderSquare)}
+ {this.renderMessage()}
</entity>
)
}
And we will define renderMessage
as this:
renderMessage() {
const state = store.getState()
const { whiteTurn } = state.game
const { playerWhite, playerBlack, status } = state.match
const yourTurn =
(whiteTurn && playerWhite === this.id) ||
(!whiteTurn && playerBlack === this.id)
const theirTurn =
(whiteTurn && playerBlack === this.id) ||
(!whiteTurn && playerWhite === this.id
return status === 'checkmate' ? (
<text
value="Checkmate!"
color="#FF0000"
position={{ x: 3.5, y: 2, z: 3.5 }}
width={3}
billboard={7}
/>
) : yourTurn ? (
<text
value="It's your turn!"
color="#000000"
position={{ x: 3.5, y: 2, z: 3.5 }}
width={2}
billboard={7}
/>
) : theirTurn ? (
<text
value="It's your opponent's turn"
color="#AAAAAA"
position={{ x: 3.5, y: 2, z: 3.5 }}
width={2}
billboard={7}
/>
) : null
}
And that's it! Now two players can start a match and play against each other. Any other connected client will be able to see the game but they won't be able to move any piece:
Remember that after making any changes to the code you will need to re-build the server and restart it:
npm run build
npm start
Diff: to see the full diff of changes for this step check this commit.
Fast-Forward: to jump to the end of this step run git clone --branch step-2 https://github.com/cazala/decentraland-redux-chess-app.git --depth=1