Skip to content

Space Shooter Tutorial

svntax edited this page Nov 17, 2022 · 14 revisions

Space Shooter Tutorial

This tutorial will show you how to use the NEAR SDK in an existing space shooter game.

You can check the final result here.

Setup

  1. Clone the project from here: https://github.com/svntax/near-space-shooter
  2. Open the project using the C# Mono version of Godot 3.5.1
  3. Have Godot generate the C# project files by adding a C# script (you can delete it afterwards). See here for more info.

You should now have a simple space shooter game working. The controls for the game are all mouse/touch only. Click or touch to press buttons and to shoot.

Screenshot of gameplay

Smart Contract Setup

The smart contract we'll be using can be found here: https://github.com/svntax/near-high-scores-tracking. You can deploy your own copy of the smart contract and use its name in place of the one being used in this tutorial (see here for more info on deploying contracts). Using dev-deploy is recommend while the game is still being developed.

The contract has two methods, a view method getScores(), and a change method submitScore(). The getScores() method returns an unsorted array of score objects, each which has a username string, and a score integer. The submitScore() method is used to submit your score to the contract, and then the contract will check if the score is high enough to enter the top 10 placements.

The project already comes with the NEAR SDK, so the rest of the tutorial will be about implementation.

High Scores Screen

The game is missing a high scores screen, so let's add one. First, we can duplicate one of the existing buttons in the main menu, rename it, and set up a new function in MainMenu.gd for its pressed() signal.

Screenshot of new high scores button

Next, let's create a new scene for the high scores screen, name it HighScoresMenu, and save it in the root project folder, the same place where the main menu scene is saved. We'll also create a new script for it with the same name, which later on will handle fetching the high scores from NEAR.

Screenshot of new files for high score menu

Now we can go back to the MainMenu.gd script, and make the game change to this new scene when the high scores button is pressed. The script should now look something like the following:

extends Node2D

func _on_StartButton_pressed():
    get_tree().change_scene("res://Gameplay.tscn")

func _on_ExitButton_pressed():
    get_tree().quit()

func _on_HighScoresButton_pressed():
    get_tree().change_scene("res://HighScoresMenu.tscn")

We can start setting up the high scores menu by adding backgrounds, text labels, buttons, and other necessary UI elements. At this point, it's up to you how you want to set up the menu. As long as there's a button to go back and a table or container of some sort with rows for the high scores, the setup will work fine.

Here is how I'll be setting up the scene:

Screenshot of high scores screen with UI elements

I copied the background and header label from the main menu, added a button for going back to the main menu, and then added a new GridContainer node with 2 columns and two placeholder Label nodes for the player's name and score. These label nodes have their horizontal size flags set to Fill and Expand in order to make them take up the entire width of the column. The player label is left-aligned, and the score label is right-aligned.

Now we can start working on the high scores script. To start, we need to connect to the NEAR network. In our MainMenu.gd script, let's check if a connection exists, and if not, connect to the testnet.

extends Node2D

var config = {
    "network_id": "testnet",
    "node_url": "https://rpc.testnet.near.org",
    "wallet_url": "https://wallet.testnet.near.org",
}

func _ready():
    if Near.near_connection == null:
        Near.start_connection(config)

func _on_StartButton_pressed():
    get_tree().change_scene("res://Gameplay.tscn")

func _on_ExitButton_pressed():
    get_tree().quit()

func _on_HighScoresButton_pressed():
    get_tree().change_scene("res://HighScoresMenu.tscn")

Then in our high scores menu script, we'll call the getScores() method from our smart contract to retrieve the high scores. The return value will be a JSON string, which when parsed, will be an array of dictionaries, each with a username and value field. Our strategy here is to use the placeholder label nodes as templates by hiding them, duplicating them and setting their respective values for each score in the array, and at the end, add them to the grid container node.

The array of scores comes unsorted, so we need to sort them first. For simplicity, and because we're dealing with a very small array, we'll use selection sort.

The HighScoresMenu.gd script should now look something like the following, including a new function for the back button:

extends Node2D

const CONTRACT_NAME = "name of your smart contract here"

onready var scores_grid = $ScoresGrid
onready var player_name_label = $ScoresGrid/PlayerName
onready var player_score_label = $ScoresGrid/PlayerScore

func _ready():
    # First, hide the placeholder labels
    player_name_label.hide()
    player_score_label.hide()
    
    # Next, fetch the high scores and create new labels for each score
    var result = Near.call_view_method(CONTRACT_NAME, "getScores")
    if result is GDScriptFunctionState:
        result = yield(result, "completed")
	if result.has("error"):
            pass # Error handling here
    else:
        var data = result.data
        var json_data = JSON.parse(data)
        var high_scores: Array = json_data.result

        # Selection sort
        for i in high_scores.size() - 1:
            var indexOfLargest = i
            for j in range(i+1, high_scores.size()):
                if high_scores[j].value > high_scores[indexOfLargest].value:
                    indexOfLargest = j
            if indexOfLargest != i:
                # Swap
                var temp = high_scores[i]
                high_scores[i] = high_scores[indexOfLargest]
                high_scores[indexOfLargest] = temp

        for score in high_scores:
            var name_label = player_name_label.duplicate()
            name_label.set_text(score.username)
            scores_grid.add_child(name_label)
            name_label.show()

            var score_label = player_score_label.duplicate()
            score_label.set_text(str(score.value))
            scores_grid.add_child(score_label)
            score_label.show()

func _on_BackButton_pressed():
    get_tree().change_scene("res://MainMenu.tscn")

Now when you run the game and go to the high scores screen, you should see something like this:

Screenshot of high scores after being fetched

If for any reason, the game fails to retrieve the high scores, we can display a message in the middle telling the user. To do so, you can add a new Label node, place it in the middle, set its text to a message like "Error: Failed to get high scores", and hide it by default. Then, in our script, show this label if an error occurred.

...
if result.has("error"):
    $MessageLabel.show()
else:
...

NEAR Wallet Login

In order to let users sign in with their NEAR wallet, we can add a login button in the main menu with that functionality.

First, let's duplicate one of the existing buttons, drag it outside of the Buttons container, move it to a corner, rename it, and set up a new function for its pressed() signal. We can also add a new Label node to display the user's name when logged in.

Screenshot of new login button

Next, in our MainMenu.gd script, after connecting to the NEAR network, we have to create a new WalletConnection, set up functions for the user_signed_in and user_signed_out signals, and make sure the login button and username label have the correct text set if the user is already logged in.

extends Node2D

onready var login_button = $LoginButton
onready var player_name_label = $PlayerNameLabel

var config = {
    "network_id": "testnet",
    "node_url": "https://rpc.testnet.near.org",
    "wallet_url": "https://wallet.testnet.near.org",
}
var wallet_connection

func _ready():
    player_name_label.hide()
    if Near.near_connection == null:
        Near.start_connection(config)
	
    wallet_connection = WalletConnection.new(Near.near_connection)
    wallet_connection.connect("user_signed_in", self, "_on_user_signed_in")
    wallet_connection.connect("user_signed_out", self, "_on_user_signed_out")
    if wallet_connection.is_signed_in():
        _on_user_signed_in(wallet_connection)

func _on_user_signed_in(wallet: WalletConnection):
    login_button.set_text("Sign Out")
    player_name_label.show()
    player_name_label.set_text(wallet.get_account_id())

func _on_user_signed_out(wallet: WalletConnection):
    login_button.set_text("Sign In")
    player_name_label.hide()

func _on_StartButton_pressed():
    get_tree().change_scene("res://Gameplay.tscn")

func _on_ExitButton_pressed():
    get_tree().quit()

func _on_HighScoresButton_pressed():
    get_tree().change_scene("res://HighScoresMenu.tscn")

func _on_LoginButton_pressed():
    pass # Replace with function body.

Then, we can crate a new constant at the top of the script for the smart contract's name, and in _on_LoginButton_pressed(), call either sign_in() or sign_out() depending on whether the user is signed in or not.

const CONTRACT_NAME = "name of your smart contract here"

# ... other code ...

func _on_LoginButton_pressed():
    if wallet_connection.is_signed_in():
        wallet_connection.sign_out()
    else:
        wallet_connection.sign_in(CONTRACT_NAME)

Now, when you run the game and click on "Sign In", you'll be redirected to the NEAR web wallet, where you can authorize the game to create a new access key, and once you're done, you can close the browser tab and go back to the game.

Screenshot of main menu after being logged in

Submitting Your Score

To submit your score to the smart contract, in the Gameplay.gd script, we'll need to create another WalletConnection object here. Note that WalletConnection is mainly a class to represent a potential connection to the NEAR network through a wallet. What this means is, we can technically create a new WalletConnection object anywhere in the game, and it will automatically know whether the user is signed in or not because the access key is stored locally in a config file, and WalletConnection looks for the key there.

Let's update Gameplay.gd to have a new wallet connection, and when the player has lost all lives, check if the player is logged in, and if so, call the submitScore() method on the smart contract.

extends Node2D

const CONTRACT_NAME = "name of your smart contract here"

onready var game_ui = $UILayer/GameUI
onready var animation_player = $AnimationPlayer
onready var spawn_system = $SpawnSystem
onready var player = $Player

var wallet_connection

func _ready():
    wallet_connection = WalletConnection.new(Near.near_connection)
    game_ui.set_lives(3)
    game_ui.set_score(0)
    animation_player.play("intro")

func _on_Player_life_lost(remaining_lives: int):
    if remaining_lives < 0:
        animation_player.play("game_over")
        spawn_system.stop_spawning()
        if wallet_connection.is_signed_in():
            var args = {"newScore": game_ui.score}
            wallet_connection.call_change_method(CONTRACT_NAME, "submitScore", args)
    else:
        game_ui.remove_life()

func _on_AnimationPlayer_animation_finished(anim_name):
    if anim_name == "intro":
        spawn_system.start_spawning()
        player.state = player.States.ALIVE

Note that the argument name must match the parameter name on the smart contract, which is "newScore" in this case.

Final Notes

At this point, you should now have a working NEAR space shooter game! You can check out the finished version of the game with NEAR integration here: https://github.com/svntax/near-space-shooter/tree/complete-version (remove the tutorial_images folder after downloading/cloning).

Now there are a couple of things to point out:

  • Instead of having to create a new WalletConnection object every time we need one, you can also create a new global singleton, save one WalletConnection instance there, and that way you can access it anywhere in your code.
  • Similarly, you can have a global constant for the smart contract's name to access anywhere as needed.
  • When your game is ready to be published officially, you can change the values of the config object passed to Near.start_connection() to connect to mainnet instead of testnet.
  • (IMPORTANT): If exporting to Android, make sure the Internet permission is on. The SDK requires internet permissions because when a user is redirected to the NEAR web wallet, the SDK creates a temporary local HTTP server in order to capture the redirect that happens after you've authorized the game.
  • Technically, there is no "server-side" verification being done when a user submits a score, so you will have to decide how you want to handle that in your game. Furthermore, the smart contract has a max of 10 entries for the high scores, which you can change if you want, but you'll have to update the high scores screen to account for that.