Introduction

After 15 years as a software engineer, I realized I had never actually built and published a game.

Since I grew up in šŸ‡¦šŸ‡· Argentina playing card games with my friends, I figured I’d choose one of those. I asked myself:

most-common-card-games

Truco: 3 Months Without LLMs

On June 18th of 2024 I started building Truco in my free time. As a longtime Go backend developer, the backend was obvious. The challenge was the UI and long-term hosting without a paid server.

ProblemSolution
UIBit the bullet and learned the minimal required React for the UI.
No serverTranspiled the server to WASM using TinyGo.
HostingUsed GitHub Pages to host the static files.

This was pre-LLM, so every detail had to be figured out by hand. It took about 3 months of trial and error to get it ready.

I never planned to advertise or monetize it; I just wanted to finish, and maybe give someone the joy of playing their childhood game again. A year later, without any extra effort on my part, people are still playing it!

truco-analytics

In case you want to check it out, here are some links for it:

Truco (play the game)

Backend in Go

Frontend in React (don’t judge me šŸ¤·ā€ā™‚ļø best I can do with 1-hour React knowledge)

“Escoba”: 3 Days With LLMs

A year later, visiting family in Argentina, I taught my nephew Escoba—the country’s second most popular card game (despite what ChatGPT insists).

two-most-common-card-games

With LLMs now mainstream, I wondered how much faster building a game might be—so I decided to test it.

I cloned the backend for Truco and gave Claude a long prompt explaining the rules of Escoba and asking it to refactor the code to implement it. To my surprise, it worked almost perfectly on the first prompt 😱. For a moment I thought: goodbye, job 😰.

prompt

The only bug I found was that it used append incorrectly in one place and mutated actions. Except for that, I only added a few bells and whistles on top (like a better bot).

The frontend was a different story; it took me a few days to get it right. The real challenge probably wasn’t just the LLM — it was my own React skills, combined with the unusual setup of letting a black-box WASM function manage game state. Debugging in JavaScript also didn’t make life easier.

In case you want to check it out, here are some links for it:

Escoba (play the game)

Backend in Go

Frontend in React

Step-by-Step: How to Build Your Own Game

I’m assuming you might have come here to see if it’s not too hard to give it a try yourself! So I’ll give you the minimalistic primer to make your own game with this stack.

I wrote a minimalistic Tic-Tac-Toe game set of repos so you can fork them to get started:

https://github.com/marianogappa/tictactoe-backend https://github.com/marianogappa/tictactoe-frontend

You can check it out here:

https://marianogappa.github.io/tictactoe-frontend/

TicTacToe

Backend-side

A turn-based backend is straightforward to outline:

That’s it!

Note: forget human vs human, unless you’re willing to pay for that server.

Frontend-side

I’m no frontend expert, but the tasks are simple:

  • Call the backend to create a new GameState.
  • Render it in the UI.
  • Let the player pick an action from the valid options.
  • Call the backend to apply the action.
  • Trigger the bot’s action when it’s their turn.

That’s it!

Backend-side interop

To transpile the backend to WASM, you can build with (docs here):

GOARCH=wasm GOOS=js go build -o main.wasm main.go

But that produces huge binaries (which is slow on mobile). Use TinyGo for smaller ones.

Before compiling, you need a different entrypoint to the functions that you’re going to make available to the frontend. Make a different main.go file that exports the functions that you need, and guard it via build flags:

//go:build tinygo
// +build tinygo

package main

[...]

func main() {
	js.Global().Set("trucoNew", js.FuncOf(trucoNew))
	js.Global().Set("trucoRunAction", js.FuncOf(trucoRunAction))
	js.Global().Set("trucoBotRunAction", js.FuncOf(trucoBotRunAction))
	select {}
}

var (
	state *truco.GameState // "Global variable" for the GameState
	bot   truco.Bot
)

Don’t forget to block at the end of main() with select {} to prevent the program from exiting immediately.

Backend data interop

GameState is typically a free-form struct that you define in Go. WASM can’t directly serialize/deserialize structs. The trick is to pass everything as JSON. After digging through TinyGo docs, here’s the formula:

func trucoRunAction(this js.Value, p []js.Value) interface{} { // Always this signature
	// Read the input JSON
    jsonBytes := make([]byte, p[0].Length()) 
	js.CopyBytesToGo(jsonBytes, p[0])

	// 1. Decode the input JSON to your struct
    // 2. Run your Go code, return an output struct
	// 3. Encode the output struct to JSON
	newBytes := _runAction(jsonBytes)

	// Return the output JSON
	buffer := js.Global().Get("Uint8Array").New(len(newBytes))
	js.CopyBytesToJS(buffer, newBytes)
	return buffer
}

Frontend-side interop

Finally, call the backend functions from the frontend and [track the GameState in a global variable]((https://github.com/marianogappa/truco-argentino/blob/main/src/gameState.js#L19):

function jsRunAction(data) {
    const encoder = new TextEncoder();
    const encodedData = encoder.encode(JSON.stringify(data));
    const result = trucoRunAction(encodedData);
    const json = new TextDecoder().decode(result);
    return JSON.parse(json);
}

let gameState = jsNewGame();

// Note that RunAction doesn't take a GameState.
// WASM is the source of truth; your frontend can't mutate it.
gameState = jsRunAction(action); 

Every time you modify the backend, you need to recompile it to WASM and replace the old file in the frontend. I put this in the Makefile:

compile_library:
	cd $(GOPATH)/src/github.com/marianogappa/escoba && \
	TINYGOROOT=/usr/local/Cellar/tinygo/0.38.0 tinygo build -o main.wasm -target wasm main_wasm.go && \
	mv main.wasm $(CURDIR)/public/wasm/wasm.wasm && \
	cp /usr/local/Cellar/tinygo/0.38.0/targets/wasm_exec.js $(CURDIR)/public/wasm/wasm_exec.js && \
	cd -

Note that I’m also copying over wasm_exec.js. This is a requirement for running WASM code. The other requirement is to add the script tag to the HEAD of the HTML file:


    <script src="wasm/wasm_exec.js"></script>
	<script>
        const go = new Go(); // Defined in wasm_exec.js
        const WASM_URL = 'wasm/wasm.wasm';

        var wasm;
        let wasmReady = false;

        if ('instantiateStreaming' in WebAssembly) {
            WebAssembly.instantiateStreaming(fetch(WASM_URL), go.importObject).then(function (obj) {
                wasm = obj.instance;
                go.run(wasm);
                wasmReady = true;
            })
        } else {
            fetch(WASM_URL).then(resp =>
                resp.arrayBuffer()
            ).then(bytes =>
                WebAssembly.instantiate(bytes, go.importObject).then(function (obj) {
                    wasm = obj.instance;
                    go.run(wasm);
                    wasmReady = true;
                })
            )
        }
    </script>

Troubleshooting

The WASM file is not loading

This works automatically in Github Pages, but locally, you need to serve the files over HTTP. You can use http-server for this:

npx http-server ./public -p 8080

And then visit http://localhost:8080 in your browser.

Conclusion

I had a lot of fun making these games and I hope you find it interesting to see how it works. I also hope you find it useful to make your own games! If you have questions, I’m not hard to find.

escoba