Defold HTML5 with Solana SDK connect to Phantom wallet

Defold HTML5 with Solana SDK connect to Phantom wallet

Hi everyone, does anyone use Defold? I'm not familiar with this game engine; however, after reading the official site, it looks good for cross-platform use and supports more than 10 platforms. I am also interested in integrating the Solana Web3 SDK into Defold. If you want to start using Defold, let's check out their official site. If you're interested in deploying Defold as a web game and accepting payments with Solana, see below. Not suitable for those who are never familiar with Defold.

Notice, Defold is complex and unique in its own way. I'm not showing the step-by-step building of the game flow, but you can extract it from my source that is provided at the end of this thread. I assume that you know or at least understand the basics of the Defold editor already to get started with this. Just read and get to know a concept to be functional.

Expected Result

Required

Windows 11

Defold [download]

Phantom Wallet desktop version [download**beware fake Phantom apps.

Optional (Required for testing only)

Microsoft Edge [download] *or other browsers that support the Phantom Wallet browser extension.


Setup Phantom App [for testing only]

Download the Phantom Wallet app and install it from the previous section. Thereafter, open it and log in with your password. **If you have no account, you can follow this instruction to create a new wallet. 

After logging in, click on Avatar, then choose Account, and click on Settings > Developer Settings. Turn on Testnet mode and select Solana Devnet mode.

If your settings were corrected, you should see a notice on your Phantom app like this. "You are currently in testnet mode. 


Get free SOL for testing

If your account does not have SOL, you cannot pay for anything because any transaction requires a gas fee. Open this url to request an Airdrop, then copy your public address and paste it here. Select the SOL amount you need, then click Confirm Airdrop.


If it's successful, you'll see a message on the bottom-right of the screen.

    

Then take a look at the SOL in your Phantom Wallet; here, your SOL is ready for testing. **I recommend creating 2 wallet accounts for testing and transferring one to another account.


Create a new project. *or adapt to your project
create a new project with "Empty Project."



I put the Defold and Solana images in assets that are not necessary for you; right-click to create a button and give it an ID, "ButtonConnect." This ID will be used in the Lua script later.


Create another collection as a game. collection, I give main as login/connect and game as scene after login. This scene consists of 3 buttons and 2 text fields. ButtonDisconnect, ButtonBalance, and ButtonBuyItem are given an ID and text fields as "textSol" to display SOL balance and "textPotion" to display Potion amount.

Create Solana Bridge
Create all file structures like the below image. All of them are Lua files.


core_js.lua
Let's start from the closet Phantom app side "core_js.lua". we wrap up javascript in Lua syntax. The top of javascript function mmake sure window.SolanaWeb object initialized once if it never created.

After that we need to inject solana web3 sdk to body of html, we need this for 2 function later for fetch SOL balance and transactions. 

Inside window.SolanaWeb create wallet object, you can create any other object than wallet later it useful for create your own sdk. I declare cluster and public_address here this 2 variables in use for this sample.

local M = {}

M.CODE = [[
(function(){
    if (window.SolanaWeb) return;
    const script = document.createElement("script");
    script.src = "https://unpkg.com/@solana/web3.js@latest/lib/index.iife.js";
    document.body.appendChild(script);

    window.SolanaWeb = {
        wallet: {
            cluster: "devnet",
            public_address: null,
            jStr: function (isSuccess, strData) {
                var obj = { success: isSuccess, data: strData};
                return obj;
                //return JSON.stringify(obj);
            },
            async connect() {
                const wallet = window.SolanaWeb.wallet;
                try {
                    if (window.solana && window.solana.isPhantom) {

                        const resp = await window.solana.connect();
                        wallet.public_address = resp.publicKey.toString();
                        console.warn("pub key: " + resp.publicKey.toString());
                        return wallet.jStr(true, resp.publicKey.toString());
                    } else {
                        console.warn("err wallet not found >> ");
                        return wallet.jStr(false, 'Phantom wallet not found');
                    }
                } catch (err) {
                    console.warn("catch err wallet >> ");
                    var res = wallet.jStr(false, err.toString());
                    console.log("res ", res);
                    return res;
                }
            },
            async disconnect() {
                const wallet = window.SolanaWeb.wallet;
                if (window.solana && window.solana.isConnected) {
                    try {
                        await window.solana.disconnect();
                        console.log('Disconnected from wallet ');
                        return wallet.jStr(true, "success disconnected.");
                    } catch (err) {
                        console.warn('Disconnect failed:', err);
                        return wallet.jStr(false, err.message);
                    }
                } else {
                    console.warn("unknown error.");
                    return wallet.jStr(false, "unknown error.");
                }
            },
            async fetchSolBalance(publicAddress) {
                const wallet = window.SolanaWeb.wallet;
                try {
                    const connection = new solanaWeb3.Connection(
                        solanaWeb3.clusterApiUrl(wallet.cluster),
                        "confirmed"
                    );

                    const pubkey = new solanaWeb3.PublicKey(wallet.public_address);

                    const lamports = await connection.getBalance(pubkey);

                    var SOL_BALANCE = lamports / solanaWeb3.LAMPORTS_PER_SOL;

                    console.log("SOL Balance:", SOL_BALANCE);
                    return wallet.jStr(true, SOL_BALANCE.toString());

                } catch (error) {
                    console.error("Balance fetch error", error);
                    return wallet.jStr(false, error.message);
                }
            },
            async payWithSol(publicAddress, recipientAddress, solValue) {
                const wallet = window.SolanaWeb.wallet;
                console.warn("solValue: "+solValue);
                //console.warn("public_address: "+wallet.public_address);
                //console.warn("receive_address: "+recipientAddress);
                try {
                    // when release change to "mainnet-beta", "devnet" use for testing only
                    const clusterApi = solanaWeb3.clusterApiUrl(wallet.cluster);
                    const connection = new solanaWeb3.Connection(clusterApi);

                    const fromPublicKey = new solanaWeb3.PublicKey(wallet.public_address);
                    //console.warn("fromPublicKey: "+fromPublicKey);

                    const toPublicKey = new solanaWeb3.PublicKey(recipientAddress);
                    //console.warn("toPublicKey: "+toPublicKey);

                    // Ensure solValue is a number
                    const solAmount = parseFloat(solValue);
                    const toLamports = Math.round(solAmount * solanaWeb3.LAMPORTS_PER_SOL);
                    //console.warn("toLamports: "+toLamports);

                    const transaction = new solanaWeb3.Transaction().add(
                        solanaWeb3.SystemProgram.transfer({
                            fromPubkey: fromPublicKey,
                            toPubkey: toPublicKey,
                            lamports: toLamports,
                        })
                    );

                    transaction.feePayer = fromPublicKey;
                    transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
                    //console.warn("transaction:", transaction);

                    const res = await window.solana.signAndSendTransaction(transaction);
                    //console.warn("res signature: "+res.signature);

                    const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();

                    const confirm = await connection.confirmTransaction({
                        signature: res.signature,
                        blockhash: blockhash,
                        lastValidBlockHeight: lastValidBlockHeight
                    });

                    //const confirm = await connection.confirmTransaction(res.signature);
                    var strJson = JSON.stringify(confirm);
                    console.warn("strJson:", strJson);
                    return wallet.jStr(true, strJson);

                } catch (error) {
                    console.error("err >> ", error);
                    return wallet.jStr(false, error.message);
                }
            },
        }
    };

    console.log("SolanaWeb injected (Lua)");
})();
]]

return M

main 4 functions inside wallet is connect. disconnect, fetchSolBalance and payWithSol. for jStr i've use to wrap result to json object of first param as boolean for success or not and second param is base string of result it possibility both string of data or string of error message.

wallet.lua
head step back to "wallet.lua" where to calling core_js and retrieve result. However all function inside wallet.lua calling through bridge.lua with passing javascript code that calling core_js.lua function insided.

So first we need to import bridge.lua here and all 5 functions can call bridge function both connect and disconnect we've use call_async from bridge that no need to pass any param to it. For fetc_async pass one param and pay_async pass 3 params, i'm not counting callback that generally need as usual except initialized we call bridge.ensure to inject solana web3 sdk only once. (you can modify to get result of injection as you need) **FYI pubAddr already pass but in javascript is not using now. this is a challenge for you.

local bridge = require "solana_bridge.bridge"

local M = {}

function M.initialized()
    bridge.ensure()
end

function M.connect(callback)
    bridge.call_async([[
    console.warn("call connect ");
    var connect = await window.SolanaWeb.wallet.connect();
    console.log("connect ", connect);
    return connect;
    ]], callback)
end

function M.disconnect(callback)
    bridge.call_async([[
    var disconnect = await window.SolanaWeb.wallet.disconnect();
    console.log("disconnect ", disconnect);
    return disconnect;
    ]], callback)
end

function M.fetchSolBalance(pubAddr, callback)
    bridge.fetch_async([[
    var addr = "%s"; // Injected from Lua
    var balance = await window.SolanaWeb.wallet.fetchSolBalance(addr);
    console.log("balance ", balance);
    return balance;
    ]], pubAddr, callback)
end

function M.payWithSol(pubAddr, recvAddr, sol, callback)
    bridge.pay_async([[
    var transaction = await window.SolanaWeb.wallet.payWithSol(param1, param2, param3);
    console.log("transaction ", transaction);
    return transaction;
    ]], pubAddr, recvAddr, sol, callback)
end

return M

bridge.lua
head step back to "bridge.lua" that "wallet.lua" passing javascript to any functions. Almost pairs function connect, disconnect, fetchSolBalance and payWithSol will be catch result here. And update function will be waiting result from every function "window._bridge_results" and callback since Lua no listener trigger like other then we need loop update to listen it.

local core_js = require "solana_bridge.js.core_js"
local M = {}

local injected = false
local callbacks = {}
local request_id = 0

-- inject JS once
function M.ensure()
    if injected then return end

    html5.run(core_js.CODE)

    injected = true
end

-- async call
function M.call_async(js_code, callback)
    M.ensure()

    request_id = request_id + 1
    local id = request_id

    callbacks[id] = callback

    html5.run(string.format([[
    (async () => {
        if (!window._bridge_results) window._bridge_results = {};

        try {
            const result = await (async function(){
                %s
            })();
            console.warn("call_async result >> ", result);
            window._bridge_results[%d] = JSON.stringify(result);
        } catch(e) {
            console.warn("call_async e >> ", e.message);
            window._bridge_results[%d] = JSON.stringify({
                error: e.message
            });
        }
    })();
    ]], js_code, id, id))
end

-- async call
function M.fetch_async(js_code, param, callback)
    M.ensure()

    request_id = request_id + 1
    local id = request_id

    callbacks[id] = callback

    html5.run(string.format([[
    (async () => {
        if (!window._bridge_results) window._bridge_results = {};

        const js_param = "%s";
        console.warn("fetch_async js_param >> ", js_param);
        try {
            const result = await (async function(param){
                %s
            })(js_param);
            console.warn("fetch_async result >> ", result);
            window._bridge_results[%d] = JSON.stringify(result);
        } catch(e) {
            console.warn("fetch_async e >> ", e.message);
            window._bridge_results[%d] = JSON.stringify({
                error: e.message
            });
        }
    })();
    ]], param, js_code, id, id))
end

-- async call
function M.pay_async(js_code, param1, param2, param3, callback)
    M.ensure()

    request_id = request_id + 1
    local id = request_id

    callbacks[id] = callback

    html5.run(string.format([[
    (async () => {
        if (!window._bridge_results) window._bridge_results = {};

        const js_param1 = "%s";
        const js_param2 = "%s";
        const js_param3 = %f;
        try {
            const result = await (async function(param1, param2, param3){
                %s
            })(js_param1, js_param2, js_param3);
            console.warn("call_async result >> ", result);
            window._bridge_results[%d] = JSON.stringify(result);
        } catch(e) {
            console.warn("call_async e >> ", e.message);
            window._bridge_results[%d] = JSON.stringify({
                error: e.message
            });
        }
    })();
    ]], param1, param2, param3, js_code, id, id))
end

-- update loop (dispatch callbacks)
function M.update()
    local json_str = html5.run([[
    (function(){
        if (window._bridge_results) {
            return JSON.stringify(window._bridge_results);
        }
        return "";
    })();
    ]])

    if not json_str or json_str == "" then return end

    local results = json.decode(json_str)
    if not results then return end

    for id, json_value in pairs(results) do
        local cb = callbacks[tonumber(id)]

        if cb then
            local data = json.decode(json_value)
            cb(data)

            callbacks[tonumber(id)] = nil

            html5.run("delete window._bridge_results[" .. id .. "]")
        end
    end
end

return M

sdk.lua
head step back to "sdk.lua" this is the main listener that wrap bridge.update, and import wallet here, if you have other plugin you can define like
 
SolanaApi.crypto = require "solana_bridge.plugins.crypto"

so sdk like a collection of plugins.

local bridge = require "solana_bridge.bridge"

local SolanaApi = {}

SolanaApi.wallet = require "solana_bridge.plugins.wallet"

function SolanaApi.update(dt)
    bridge.update()
end

return SolanaApi

Now, you've done solana bridge part, next we need to implement on the game/app side. 

Connect Wallet
from main folder we need 2 script main.script and main_menu.gui_script, main.script is doing like load and unload scene, nothing refer to solana bridge.


main_menu.gui_script
at the top of file we need to import solana_bridge.sdk here, also in init function we call solanaBridge.wallet.initialized() here, initialize only once here. 

local solanaBridge = require "solana_bridge.sdk"

function init(self)
msg.post(".", "acquire_input_focus")
self.button_connect = gui.get_node("ButtonConnect")
print("Login Init ")
solanaBridge.wallet.initialized()
end

then inside update function, we need to call solanaBridge.update(dt) as mention above to listen all any event of javascript proceed and callback to lua.

function update(self, dt)
-- Add update code here
-- Learn more: https://defold.com/manuals/script/
-- Remove this function if not needed
solanaBridge.update(dt) -- required
end

the last function is on_input, we capture event mouse click button connect then call solanaBridge.wallet.connect invoke Phantom app, once connect success we save public_address here then change scene to game scene collection.

function on_input(self, action_id, action)
if action_id == hash("touch") or action_id == hash("click") then -- Configure "touch" or "click" in your Input bindings
if action.released and gui.pick_node(self.button_connect, action.x, action.y) then
print("button_connect ", self.button_connect)
-- Button clicked, now change the scene

solanaBridge.wallet.connect(function(res)
print("input success:", res.success)
print("input data:", res.data)
if res.success then
local path = sys.get_save_file("phantom", "account")
local data = { public_address = res.data }
sys.save(path, data)
msg.post("main:/main#main", "load_game") -- Send message to the collection proxy
end
end)
end
end
return true -- Return true to consume the input
end

Transaction
Now we've done first main scene collection. Move to game scene collection. 3 features on this scene as disconnect, fetch SOL balance and pay with SOL, then simulate update Potion quantity also check balance update after purchased.

game_menu.gui_script
since scene load or unload already done at main scene then we've to do only all gui script for game scene for 3 buttons and 2 text fields. At the top of file we need to import solana_bridge.sdk too. then we need recevier wallet address here.

Inside init function after initialize all button and text field, then we call fetch_sol function here, inside fetch_sol function will load public_address fromm connected Phantom wallet. then pass it through solanaBridge.wallet.fetchSolBalance then let solana web3 sdk handle the rest, once success we'll get SOL new balance and update to text field on screen.

local solanaBridge = require "solana_bridge.sdk"
local recv_addr = "xxxx" --your receive SOL wallet address
local total_potion = 0;

function fetch_sol()
local path = sys.get_save_file("phantom", "account")
local loaded_data = sys.load(path)
local pub_addr = loaded_data.public_address or ""
solanaBridge.wallet.fetchSolBalance(pub_addr, function(res)
print("balance success:", res.success)
print("balance data:", res.data)
if res.success then
-- 1. Get the reference to the node by its ID (set in the editor)
local sol_node = gui.get_node("textSol")
-- 2. Update the string
gui.set_text(sol_node, res.data .. " SOL")

print("balance data:", res.data)
end
end)
end

function init(self)
msg.post(".", "acquire_input_focus")
self.button_disconnect = gui.get_node("ButtonDisconnect")
self.button_balance = gui.get_node("ButtonBalance")
self.button_buy_item = gui.get_node("ButtonBuyItem")
local potion_node = gui.get_node("textPotion")
gui.set_text(potion_node, "Potion x " .. total_potion)

print("Game Init ")
fetch_sol()
end

liked main_menu.gui_script we need "solanaBridge.update" in update function of game_menu.gui_script too.

function update(self, dt)
-- Add update code here
-- Learn more: https://defold.com/manuals/script/
-- Remove this function if not needed
solanaBridge.update(dt) -- required
end

the last chunk is "on_input" function, first when disconnect button click, remove save of public_address then go back to main scene. Second is fetch_sol when click balance button wll call request recent SOL balance, last button is buy item button, we need to load public_address to pass as first param, the second param is receiver_address and the third param is SOL amount as 0.002 i always use to test as minimum spending per transaction depend on your item price.

function on_input(self, action_id, action)
if action_id == hash("touch") or action_id == hash("click") then -- Configure "touch" or "click" in your Input bindings
if action.released and gui.pick_node(self.button_disconnect, action.x, action.y) then
print("button_disconnect ", self.button_disconnect)
-- Button clicked, now change the scene
solanaBridge.wallet.disconnect(function(res)
print("discon success:", res.success)
print("discon data:", res.data)
if res.success then
local path = sys.get_save_file("phantom", "account")
local success, reason = os.remove(path)

if success then
print("Save file deleted successfully")
else
print("Could not delete save: " .. reason)
end
msg.post("main:/main#main", "unload_game") -- Send message to the collection proxy
end
end)
end

if action.released and gui.pick_node(self.button_balance, action.x, action.y) then
print("button_balance ", self.button_balance)
-- Button clicked, fetch balance
fetch_sol()
end

if action.released and gui.pick_node(self.button_buy_item, action.x, action.y) then
print("button_buy_item ", self.button_buy_item)
-- Button clicked, prompt confirm pay with sol
local path = sys.get_save_file("phantom", "account")
local loaded_data = sys.load(path)
local pub_addr = loaded_data.public_address or ""

solanaBridge.wallet.payWithSol(pub_addr, recv_addr, 0.002,function(res)
print("pay success:", res.success)
print("pay data:", res.data)
if res.success then
total_potion = total_potion + 1
local potion_node = gui.get_node("textPotion")
gui.set_text(potion_node, "Potion x " .. total_potion)

fetch_sol()
end
end)
end
end
return true -- Return true to consume the input
end

Now we've done implement Solana Integration, let's test it.

Export Defold HTML5 game
I recommend going with "Project > Clean Build HTML5. "Make sure there's no cache. 


Your default browser will be launched similarly to the below image.


Click "connect"; the prompt Phantom app will open to ask you to connect if you never connected before or your session expired, and then our function fetch_sol should be triggered. Also, the text "loading..." will be changed to your SOL number.


then click "Buy Item." Inside the function, we already provided a pass of 0.002 SOL as minimum SOL spending. Let's see SOL's current number also, Potion x 0. 

Once confirmed and the transaction is complete, you should see the SOL balance update and Potion x 1.

Done. Your Defold HTML5 game can connect to Phantom Wallet and pay with SOL. Remember this cluster is on devnet for testing only. Make sure it works properly on the mainnet-beta cluster when running as production.
 
Hope you enjoy and have fun with Solana integration. See you next thread ;)

Buy Me a Coffee.

Free Download Content Updated!!

Provide Defold sample and SolanaBridge
>> Download << 

The challenge 
from this sample as mention before i'm not using public address that save and load from lua pass back to javascript but use cache public address form connected response in javascript instead. Checkout why?