canastra.online

canastra.online is a browser-based single/multiplayer card game that I’ve build in 2018 and opened to the public in 2023.

Context

This project was built to replace an older version. Read the old write up.

At the beginning of 2023, I had been writing a bit of Go here and there, mostly specific-purposed HTTP microservices, and I wanted to get more experience with the language.

While there was nothing wrong with the PHP version of Canastra, I thought it was a nice project to rewrite in Go, because it had a decent array of paradigms/problems that would be useful to know how to solve them using Go, and it would also introduce me to a few challenges that I haven’t had the need in the past, like memory management and threads/coroutines.

Architecture

Like the PHP version, the Go version is also split between a library, which controls the game state and mechanics, and the frontend/server, which takes care of the browser communication and data persistence.

canastra-lib

The PHP version was built using TDD, so I had a huge array of tests already, which made my life a lot easier. This library has to behave exactly like the old one, so most of the work went into copying the tests over and making sure they still meant and tested the same things as before. Given the PHP code was OOP, it wasn’t possible to translate the same concepts, making me hit a few walls.

This was definitely the exercise that taught me the most about Go as a language. I had to revise my mental models of what structs, interfaces and OOP meant in Go (vs PHP), I had to re-learn error handling the procedural way (as opposed to try/catch), I had to rewrite things that I took for granted in the PHP ecossystem, like Data Providers and RecursiveArrayIterator.

There are lots of situations in which PHP would have native functions or one-liners, and I’m specially missing ternary operators in Go, but I managed to complete the library.

A few code excerpts:

Click to expand
func (action ActionDrawFromDeck) Perform(game *Game, player *Player) (*ActionResult, error) {
	cards, err := game.deck.draw(1)

	if err != nil {
		return nil, err
	}

	game.hands[player.ID].addCards(cards)

	return &ActionResult{
		Type: ActionTypeDrawFromDeck,
		Data: ActionResultDataDrawFromDeck{Card: cards[0]},
	}, nil
}
func TestDeckDraw(t *testing.T) {
	type payloadType struct {
		Deck     []string
		Amount   int
		Expected []string
	}

	var dataProvider = []payloadType{
		{
			Deck:     []string{"AC", "2S", "3S", "4C"},
			Amount:   1,
			Expected: []string{"4C"},
		},
		{
			Deck:     []string{"AC", "2S", "3S", "4C", "5D"},
			Amount:   2,
			Expected: []string{"4C", "5D"},
		},
	}

	for _, data := range dataProvider {
		deckCards := convertStringsToCards(data.Deck)
		expectedCards := convertStringsToCards(data.Expected)

		deck := Deck{cards: deckCards}
		drawnCards, err := deck.draw(data.Amount)

		if err != nil {
			t.Fatalf("Expected no error from deck.draw(), got %s", err)
		}

		if len(drawnCards) != data.Amount {
			t.Errorf("Expected to draw %d cards, got %d", data.Amount, len(drawnCards))
			continue
		}

		if assertCardsContainAll(drawnCards, expectedCards) != true {
			t.Errorf(
				"Expected drawnCards to contain %s, got: %s",
				strings.Join(convertCardsToStrings(expectedCards), ","),
				strings.Join(convertCardsToStrings(drawnCards), ","),
			)
		}

		if assertCardsContainAny(deck.cards, drawnCards) == true {
			t.Errorf(
				"Expected deck.cards to not contain %s after draw, got: %s",
				strings.Join(convertCardsToStrings(drawnCards), ","),
				strings.Join(convertCardsToStrings(deck.cards), ","),
			)
		}
	}
}

canastra-server

This piece has mostly the same responsibilities as before, but it changed a lot. Everything has been thread-safe so far, but now the multi-concurrency fun begins. Despite the game “just working” with the PHP version, rewriting everything into Go exposed me to some problems that I didn’t realize, and it’s likely that the old version with Ratchet would have been terrible to debug for concurrency issues.

One example is the join endpoint, which uses a WebSocket to account for each player, and once two have joined the same game ID, I’d redirect them to the actual play page. In the new Go version I had introduced the “Random game” options, so more than two people could be connected at any time, and solving for that gave me so much insight on how go routines work and access memory and the importance of mutexes and how to use them correctly to avoid everything locking up.

Other than that, it wasn’t a rough sea of problems, but just a long journey of porting all elements and learning how to handle each particular requirement in Go. I had no major hiccups, and somewhere around August I’ve put this new version into production.

Deployment

Deploying to production was simplified by having a single binary with everything, including the HTTP server. No more Docker, Nginx, PHP-FPM and supervisord, just a single binary and a systemd unit. Because of this simplification, I could also implement a simple auto-deploy process via GitHub Actions, but I’ll save that for a dedidated blog post.

New frontend

The old frontend was built with Twitter Bootstrap and at this point I was getting sick of it, mostly because all my other projects were using it too. The mobile support also wasn’t great, so I decided to modernize it. I scrapped all the CSS and wrote it from the scratch with Flexbox and a bit of Tailwind (mostly for the color scheme), using a mobile-first approach.

It turned out decent, but visually it didn’t change much. For a long time I want to hire an UI/UX freelancer to make it look decent, but that will have to wait for a little longer. Or if you know someone that would be interested, send them my way!

Canastra on the Go

An interesting and unexpected spin-off for the project was Canastra on the go. Go makes compiling for ARM64 trivial, so I just had to add support for a secondary data storage, which in this case was a JSON file. I’m currently manually applying changes needed for the Captive Portal, so it’s on my queue to learn is how to write the project in a more modular approach, so I can keep a single codebase for the game.

Single player mode

I mentioned in the previous write up about building a bot with TypeScript to bring the single player capability. I had tried that last year but quickly abandoned, mostly because I’d have to rewrite the game library in TypeScript (or at least some of the validations), so the bot could do something better than just spam the server with random actions until they worked.

With the Go rewrite, the effort became a lot more palatable. At the end of the first day I already had bots playing against each other, and that opened a myriad of bugs and ideas. I baked it into the main server binary and put it live, and it was a quick success. A few of my relatives got really hooked into the game, even with a dumb bot. I tried my hand at making the bot AI-powered, but that didn’t make to production.

There is a lot of room to improve the bot, both with static logic and with machine learning, so I’ll be slowly adding those changes as I see them. After the public launch I also added a Top 10 to the website, so maybe my new game is to make the bot better.

Public launch

With the success of the single player mode within my family, I decided it was time to push it publicly. I bought the canastra.online domain and launched it over the weekend. My wife then went ahead and set up an Instagram account to post updates, as a bunch of our initial public were suggesting this.

For my expectations, it has been a huge success. Today, 11 days after the launch, I’m seeing an average of 100 games per day being played, 93% in the single player mode. I had no idea the single player mode would be so successful. I had someone mentioned to me they were playing while waiting for their Uber, and I had played it while commuting a few times already (using the Pi Zero). While the original game takes 20-30 minutes when playing in real life, a single player session can take less than 5 minutes, and it’s also possible to stop it mid-game and pick it up later, without annoying your opponent.

Thank you

I did not have clear goals for the project in the beginning, and the rewrite was just a fun mind exercise. However once other people started playing the single player version and clearly showing interest, it was suddenly clear to me how this would take shape.

I’d like to thank everyone in my family for the support, testing and bug reports, and I hope to be able to keep the game ad-free and public for the foreseeable future. I have no plans to monetize the game in any way, but I’m open to donations if anyone wishes to support the game.

Thank you!