For tech stack today:
- #golang for the backend, because I am most productive with it.
- #postgres and gorm.io for persistence. I already have Postgres on my server, so it's the easiest option, and I used gorm in the past and was happy with it.
- https://echo.labstack.com/ for the web framework. I wanted to try it for a while, and it seems like a good balance between "not too magical" and "I don't have to do boring things".
- https://github.com/google/safehtml for page rendering. Because fuck SPAs.
- Client side: hand-written JS and CSS, in the minimal quantity possible. Ideally, I'd like to get away without JS as all, but we'll see.
The code will go here: https://github.com/nevkontakte/pat
First objective: get an HTTP server up and running, with a Postgres connection and serving a hello world of sorts.
To be honest, I feel more than a little nervous putting the code of the project into a public repo right away. Usually I do small, personal stuff like that in private repos because I don't have to worry about doing things the right way, people spotting bugs or vulnerabilities, no agonizing over the license… But… If I'm gonna let the cat out of the bag, may as well make the repo public.
A primitive HTTP server is easy enough, it's almost a verbatim copy of the example from Echo's readme: https://github.com/nevkontakte/pat/commit/88f601f431d1a96222bd39cb703f8c0969501bbe
The only deviation is that I prefer various runtime parameters to be configurable via a flag. I got used to this pattern at work, and it's a good compromise between flexibility and simplicity for my purposes. I try to avoid building config-programmable applications because I may as well program the logic directly.
Also, at this stage I'm not going to bother with the file structure or tests, I want my environment and infrastructure working. Then I can throw away this placeholder and arrange everything in better ways.
Database setup is of two parts: I need a convenient local database to develop against, and I need to connect to it in my program.
For local development purposes I've been using variations of this docker-compose file: https://github.com/nevkontakte/pat/commit/58ac6455b84bfa5cf837d6cefb48c3a5e7f37187. It starts a Postgres image (technically Timescale, which I don't really need in this project, but I do use elsewhere), and a pgweb instance for me to poke around the database. This image IS NOT SECURE, it uses default passwords and all, but for my purposes it's fine.
Connecting to the database is once again, pretty much a verbatim copy of GORM's readme: https://github.com/nevkontakte/pat/commit/fe85bc10b9326e4e32ba1a9d085b361f78b3c9c0. I make a simple query to give myself concrete evidence the connection actually works, but once again this code will be thrown away shortly.
The final preparatory step, I want the thing running live at https://pat.junkie.dev.
This was supposed to be the easy step I do real quick before getting coding for real. Welp.
What went well:
- Adding Dockerfile — thanks to Go's static linking the deployment is really easy, and I just borrowed most of the file from another project: https://github.com/nevkontakte/pat/commit/09a4804adb4d7bcffb58951919f277e5cc439784. The only notable thing is use of two-phase build where the final image doesn't have Go toolchain in it.
- Adding a GitHub workflow to build, test and push a Docker image into GitHub Pacakges: https://github.com/nevkontakte/pat/commit/3745f1fad536678120b251e50bc1b9bec41a9a82. Again, copypasta from another project, with minor updates.
What went poorly:
- Spent an hour banging my head against Docker Swarm because yaml was missing an indentation level.
- Spent another hour trying to figure out why Traefik is failing ACME DNS challenge all of a sudden. Fuck if I know. In the end, switched to the TLS challenge and life moved on.
- Along the way i dove into a number of other rabbit holes that did not help overall productivity.
Simple things first, I'm gonna need to serve pictures of the cat. Thankfully a wonderful person drew them for me all those years back when I had the idea, so my absolute lack of drawing skills is not a problem.
This would be a good time to move HTTP serving logic into a new package. Usually I try to separate business logic from the framework a bit, something like this:
// Web implements the HTTP server for the pat junkie.
type Web struct{}
// Bind HTTP handlers to the Echo server.
func (w *Web) Bind(e *echo.Echo) {
e.GET("/", w.index)
}
// index page handler.
func (w *Web) index(c echo.Context) error {
return c.String(http.StatusOK, "")
}
The important bit is that I don't create or start the web server here. That makes testing easier because I can pass a differently configured server in a unit test, instead of adding test-only logic into my non-test code.
go:embed makes it incredibly easy to bundle static assets into the binary. Echo offers static serving middleware which I find a bit odd of a choice, instead of implementing it as a handler, but I don't mind that much.
Here I do a similar trick, instead of hard-coding the file system with static assets to the real one, I pass it in as a parameter. That way in test I can pass something different, or even nothing at all, if I need to. I may change my mind later, but for now this works fine.
Actually, I immediately change my opinion. I'll move the embedded FS into its own package. That'll make sense when we do a similar thing for templates, which require initialization and parsing. Putting that logic into a package only makes sense.
Anyway, look at this fine gentlemen: https://pat.junkie.dev/static/cat/idle.png
https://github.com/nevkontakte/pat/commit/035b8a6edea89392a2ce27f7077429cf498bc418
Not a lot has changed, but now we serve one emoji using templates! Also, the favicon. Making favicons out of emojis is the best lazy-ass escape hatch ever.
I wanted to get to a basic page layout with the picture, but it's past midnight, so ima stop here for now. Good news, all the lego bricks I needed for the project are now put together, next is the fun part.
Picking up to where I left off, today we will breath some personality in…
Speaking of, the cat's name is Splotch, pleased to meet you. If you give a pat.
I think to start with I want the content to be very minimal, with Splotch taking up most of the first screen space. He's the main attraction after all. He also has a short status line, and nothing else.
For the color scheme, I want to have an impression of an old, yellowed doodle that got a life of its own while nobody was looking. So, brown-ish, sepia colors. At some point I'll probably adjust the art to match it better, but it's good enough for now.
Next we need to be able to give the pats. Zero pats is no good.
We are making progress here.
This is the point where we need to get our database in order. For the time being, Splotch is the only resident here, but I feel like at some point bringing him some friends wouldn't hurt, so I'm trying to design things in a way that won't make it unnecessarily hard in future.
The database record looks something like this:
// SplotchID is the identifier of the OG, Splotch `Pat Junkie` the Cat.
const SplotchID = "splotch"
// Cat represents a database record about a single cat.
type Cat struct {
ID string // Unique identifier of the cat.
Name string // Human-readable name of the cat.
Pats uint64 // Total number of pats received by the cat.
LatestPat time.Time // Time when the latest pat was received.
}
I also need to make sure that Splotch's record is present in the database. I could insert it manually, but that's kind of a headache every time I want a fresh instance for development. I just want to go run
the thing and have it fully functional. In a bigger project, I'd use versioned database migrations.
However, for a single record that's an overkill. So instead, I try to insert a default Splotch's record with 1 pat on startup. If it succeeds, great we have initialized the clean database. If it conflicts on the primary key, no problem means we already have the record with real data, leave it be.
This approach works quite well with singleton, known ahead of time records like this, default admin accounts and so on.
Complete code in https://github.com/nevkontakte/pat/commit/a01e962915a1f44e457867e9431cfa653eb46ed9
So, now Splotch officially has one pat on his record, and I updated the index page to show that! Next we need to make patable.
You can pet the cat in this game! https://pat.junkie.dev/ That's pretty much all you can do, such as it is.
Number of pats is int64, and technically could overflow... Especially since I deliberately ain't going to limit the number of pats. But this is a problem I can deal with if it comes anywhere near it.
The only other thing of note is that I intentionally committed a cardinal sin of web development: a mutating action on a GET handler. I think it's quite funny to put web crawlers to work patting my cat.
Other than that, the commit is pretty unremarkable: https://github.com/nevkontakte/pat/commit/9af9a57042aa02acaba485083b9e6cfcae81f2df.
How, just an incrementing counter is kind of boring, the cat is supposed to react to being interacted with, let's give Splotch some of that. I am kind of leaning on the concept of zero-player games and since patting the cat is the only way of interacting, the mood will depend on that.
This commit does a rudimentary version of that. Basically, the more time has passed since the pat, the less happy Splotch gets. Okay for now, but kind of boring. The final system should at least meet these requirements:
- Consistent - every visitor at a given time should see Splotch doing the same thing. This is important to convey that Splotch is the one and only character, no matter which device you are using to visit him.
- Deterministic - given his internal state his moods must be deterministically computable. This simplifies state management, since I don't have to generate random events and save them to the database, I can compute his current state immediately on demand.
- Unpredictable - unless you know Splotch's internal state, you wouldn't be able to guess his mood at a given exactly. However, it should still correlate with the recent events, for example the same general trend of getting bored over time.
I think I have a pretty cool way if implementing it, but it's a bit too late to do it today. Maybe I'll write a long-form blog post about it.
Today's objective is to code something for fun, in a day. It's been far too long I've done something with no purpose other than enjoying the process. An idea has been kicking around my head since (checks timestamps) mid 2020... Time flies
I want to put a cat on the internet. He chills on his page and you can pet him. Or not. He likes when he's getting pets That's it. I have more ideas, but minimalism is the name for the game for today.
Technically, I want to keep things simple and lightweight. It should be clean. It should load fast. Interactions should be simple, no manual required. Giving a pat should leave a footprint of sorts.
To make things a bit more fun, this will be a live development thread