Build an Energy System for Casual Games on Solana
Casual games commonly use energy systems, meaning that actions in the game cost energy which refills over time. In this guide we will walk through how to build one on Solana. If you don't have any prior Solana knowledge, start with the Hello World Example instead.
You can easily set up a new game with an energy system and a React client using Create Solana Game. Just run the command:
npx create-solana-game your-game-name
You can find a tutorial on how to use
create-solana-game
. and also a video walkthrough of the example being explained in this guide below.
Anchor program
For starters, we will guide you through creating an Anchor program that gradually replenishes the player's energy reserves over time. The energy will enable them to execute various actions within the game. In our example, a lumberjack will chop trees with every tree rewarding one wood and costing one energy point.
Creating the player account
First the player needs to create an account which saves the state of our player.
We will also save the Unix time stamp of the player's last interaction with the
program in the last_login
value. With this state, we will be able to calculate
how much energy the player has at a certain point in time. We also have a value
for how much wood the lumberjack cuts in the game.
pub fn init_player(ctx: Context<InitPlayer>) -> Result<()> {
ctx.accounts.player.energy = MAX_ENERGY;
ctx.accounts.player.last_login = Clock::get()?.unix_timestamp;
Ok(())
}
...
#[derive(Accounts)]
pub struct InitPlayer <'info> {
#[account(
init,
payer = signer,
space = 1000,
seeds = [b"player".as_ref(), signer.key().as_ref()],
bump,
)]
pub player: Account<'info, PlayerData>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct PlayerData {
pub name: String,
pub level: u8,
pub xp: u64,
pub wood: u64,
pub energy: u64,
pub last_login: i64
}
Chopping trees
Then whenever the player calls the chop_tree
instruction we will check if the
player has enough energy and reward them with one wood (by incrementing the
player's wood count).
#[error_code]
pub enum ErrorCode {
#[msg("Not enough energy")]
NotEnoughEnergy,
}
pub fn chop_tree(mut ctx: Context<ChopTree>) -> Result<()> {
let account = &mut ctx.accounts;
update_energy(account)?;
if ctx.accounts.player.energy == 0 {
return err!(ErrorCode::NotEnoughEnergy);
}
ctx.accounts.player.wood = ctx.accounts.player.wood + 1;
ctx.accounts.player.energy = ctx.accounts.player.energy - 1;
msg!("You chopped a tree and got 1 wood. You have {} wood and {} energy left.", ctx.accounts.player.wood, ctx.accounts.player.energy);
Ok(())
}
Calculating the energy
The interesting part happens in the update_energy
function. We check how much
time has passed and calculate the energy that the player will have at the given
time. We will do the same in the client. We lazily update the energy instead of
polling it all the time. This is a common technique in game development.
const TIME_TO_REFILL_ENERGY: i64 = 60;
const MAX_ENERGY: u64 = 10;
pub fn update_energy(ctx: &mut ChopTree) -> Result<()> {
let mut time_passed: i64 = &Clock::get()?.unix_timestamp - &ctx.player.last_login;
let mut time_spent: i64 = 0;
while time_passed > TIME_TO_REFILL_ENERGY {
ctx.player.energy = ctx.player.energy + 1;
time_passed -= TIME_TO_REFILL_ENERGY;
time_spent += TIME_TO_REFILL_ENERGY;
if ctx.player.energy == MAX_ENERGY {
break;
}
}
if ctx.player.energy >= MAX_ENERGY {
ctx.player.last_login = Clock::get()?.unix_timestamp;
} else {
ctx.player.last_login += time_spent;
}
Ok(())
}
JavaScript client
Here is a
complete example
using create-solana-game
, with a React client.
Create connection
In the Anchor.ts file we create a connection to the Solana blockchain (in this case, devnet):
export const connection = new Connection(
"https://api.devnet.solana.com",
"confirmed",
);
Notice that the confirmation parameter is set to confirmed
. This means that we
wait until the transactions are confirmed
instead of finalized
. This means
that we wait until the super majority of the network said that the transaction
is valid. This takes around 400ms and there was never a confirmed transaction
which did not get finalized. Generally for games, confirmed
is the perfect
transaction commitment level.
Initialize player data
First, we will find the program address for the player account using the seed
string player
and the player's public key
(deriving the PDA). Then we call initPlayer
to create the
account.
const [pda] = PublicKey.findProgramAddressSync(
[Buffer.from("player", "utf8"), publicKey.toBuffer()],
new PublicKey(LUMBERJACK_PROGRAM_ID),
);
const transaction = program.methods
.initPlayer()
.accounts({
player: pda,
signer: publicKey,
systemProgram: SystemProgram.programId,
})
.transaction();
const tx = await transaction;
const txSig = await sendTransaction(tx, connection, {
skipPreflight: true,
});
await connection.confirmTransaction(txSig, "confirmed");
Subscribe to account updates
Next we will use websockets within the JavaScript client to subscribe and listen for changes on the player's account. We use websockets here (over manually polling the RPC) because it is a faster way to get the changes.
connection.onAccountChange
creates a socket connection to the RPC node which
will push any changes that happen to the account to the client. We can then use
the program.coder
to decode the account data into the TypeScript types and
directly use it in the game.
useEffect(() => {
if (!publicKey) {
return;
}
const [pda] = PublicKey.findProgramAddressSync(
[Buffer.from("player", "utf8"), publicKey.toBuffer()],
new PublicKey(LUMBERJACK_PROGRAM_ID),
);
try {
program.account.playerData.fetch(pda).then(data => {
setGameState(data);
});
} catch (e) {
window.alert("No player data found, please init!");
}
connection.onAccountChange(pda, account => {
setGameState(program.coder.accounts.decode("playerData", account.data));
});
}, [publicKey]);
Calculate energy and show count down
In the JavaScript client, we can now perform the same logic as in the program to precalculate how much energy the player would have at this point in time and show a countdown timer for the player so that he knows when the next energy will be available:
useEffect(() => {
const interval = setInterval(async () => {
if (gameState == null || gameState.lastLogin == undefined || gameState.energy >= 10) {return;}
const lastLoginTime = gameState.lastLogin * 1000;
let timePassed = ((Date.now() - lastLoginTime) / 1000);
while (timePassed > TIME_TO_REFILL_ENERGY && gameState.energy < MAX_ENERGY) {
gameState.energy = (parseInt(gameState.energy) + 1);
gameState.lastLogin = parseInt(gameState.lastLogin) + TIME_TO_REFILL_ENERGY;
timePassed -= TIME_TO_REFILL_ENERGY;
}
setTimePassed(timePassed);
let nextEnergyIn = Math.floor(TIME_TO_REFILL_ENERGY - timePassed);
if (nextEnergyIn < TIME_TO_REFILL_ENERGY && nextEnergyIn > 0) {
setEnergyNextIn(nextEnergyIn);
} else {
setEnergyNextIn(0);
}
}, 1000);
return () => clearInterval(interval);
}, [gameState, timePassed]);
...
{(gameState && <div className="flex flex-col items-center">
{("Wood: " + gameState.wood + " Energy: " + gameState.energy + " Next energy in: " + nextEnergyIn )}
</div>)}
With this you can now build any energy based game and even if someone builds a bot for the game the most they can do is play optimally, which may be even easier to achieve when playing normally depending on the logic of your game.
This game becomes even better when you add the ability to use tokens in games. For example, rewarding players with some SPL tokens for their actions in game..