Show the code in these languages

Pet, a case study

Pet is a Tamagotchi for kids: the pet lives on a server, the robot is its body. Kids play with the robot itself and with a picture page on a phone or tablet; parents set the daily routine on a page of their own. Its code: i41-eu/q41eu-pet at v0.14.1 (MIT), online at pet.q41.eu.

What the robot and the phone show

A Stackchan robot running the pet: a happy face, its LEDs warm
The pet on the robot: a happy face, the LEDs warm.
The pet's seven faces on the robot's screen: happy, yum, fine, yawning, asleep, hungry, bored
Its faces, one per mood, made from Fluent Emoji to fill the robot's screen (a parent can switch back to the robot's own face).

How it is built

About 6,900 lines of Go and 930 of pages. Of the kit it uses robothub for the robots' connections, sound for its sounds (the pet's chimes moved into the kit), and leds for its LED colours.

Pet's parts
PartWhat it doesLines
internal/petthe model: the needs, the daily schedule, moods, the kid's actions. No I/O: the caller passes the time and turns reactions into robot commands.847
internal/appthe robots through the hub; the engine (robot events to pet actions, moods to the face, speech, LEDs and sounds), the menus, the games, the files on the robot, the head guard, the watchdog; the pages and their API4,764 and the pages
internal/robotpicpictures for the robot's screen (the needs, menu tiles, game buttons, the podium) and the LED colours710
internal/voicethe spoken lines: made once and kept353
cmd/q41eu-petthe flags, the state (a file or Postgres), the server269

The tests (2,400 lines): the model with times passed in, and the app end to end (1,300 lines) with a fake robot and a settable clock: pairing, food cards, bedtime and the morning, the games, the sprites, the head after a game.

What it uses of the robot

What the pet sends the robot and handles, and how far Dev's API reaches it today: a program can do the first half of this table, and an app is needed for the rest.

What the pet sends and handles, and Dev's actions for it
The robotForThrough Dev
emotion, facethe robot's own faceface emotion; show clear
saythe speech bubblesay
leds: sides, pixels, effects (breathe, blink, rainbow)moods, the night light, the color gameleds
nod, shake, lookreactions, the gamesmove
speaker sound (binary 0x03), volumechimes, its voicesound (the kit's chimes, and volume)
a picture (binary 0x10)the needs, the podium, a robot without spritesshow image
sticker, brightness, screensaverhearts, the night, the games' fixed brightnesscommand
the events screen_tap (with the sprite hit), screen_long_press, head_release, head_swipe_*, nfc_tag, shake, proximity_neareverything the kid doesevents: tap (with sprite), long_press, pat, pet, stroke, tag, shaken, near
readings: imu_a*_g, imu_gyro_dps, head_yaw_deg, head_pitch_degheld in someone's hands, a sunken headstate (every 2 s)
sprite, sprite_hide; the event sprite_errormenu tiles, the food, the ball, stars, the drawn facesan app only: a program cannot store a picture on the robot
the file store: binary 0x11, assets, asset_delete, play; the events assets, asset_saved, sound_doneits pictures and sounds kept on the robotan app only
the event head_press with its three zoneshow hard: one finger dreams, the palm wakes itan app only
light_stream (binary 0x08, 20 a second)a hand coming close, in the gamean app only: Dev turns no stream on
snapshot (binary 0x07)the podium's photosan app only
the events screensaver_on, screensaver_offthe screen's statean app only

The key code

The robots, through the kit's hub

As in Focus, plus two callbacks: the robot's readings every two seconds (the head's angles, motion) and its binary streams (the light sensor, a camera photo). The pet wraps the hub's robot in its own type (robotConn) for its rules and its counters.

internal/app/robotconn.go, lines 64–103 at v0.14.1
// newHub: robots connect through the hub; the pet's state stays here.
func (a *App) newHub() *robothub.Hub {
	return robothub.New(robothub.Config{
		Token: a.cfg.RobotToken, Manager: a.cfg.Manager, PublicURL: a.cfg.PublicURL, PairTTL: a.cfg.PairTTL, Log: a.log,
		Connected: func(r *robothub.Robot) { a.attach(&robotConn{hubRobot: r, id: r.ID()}, r.Capabilities().Commands) },
		Disconnected: func(r *robothub.Robot) {
			a.mu.Lock()
			c := a.robots[r.ID()]
			a.mu.Unlock()
			if c != nil && c.conn != nil && c.conn.hubRobot == hubRobot(r) {
				a.detach(c.conn)
			}
		},
		Event:     func(r *robothub.Robot, ev wire.RobotEventBody) { a.robotEvent(r.ID(), ev) },
		Telemetry: func(r *robothub.Robot, m map[string]float64) { a.telemetry(r.ID(), m) },
		Binary: func(r *robothub.Robot, kind byte, payload []byte) {
			switch kind {
			case wire.BinLight: // the game's hand detection
				a.lightSamples(r.ID(), payload)
			case wire.BinSnapshot: // a leaderboard photo, or a screen snapshot (photos.go)
				a.robotSnapshot(r.ID(), payload)
			case wire.BinAudioMulti: // a debug recording (debug.go)
				a.micAudio(r.ID(), payload)
			}
		},
		// Browsers paired before: the robot starts with its face, not the QR screen.
		Paired: func(id string) wire.PairedBody {
			a.mu.Lock()
			defer a.mu.Unlock()
			return a.paired(id)
		},
		Owner: func(id, owner string) {
			a.mu.Lock()
			defer a.mu.Unlock()
			if rb := a.robots[id]; owner == "" || rb == nil || rb.owner != owner {
				a.setOwnerLocked(id, owner)
			}
		},
	})
}

The head stays still in a kid's hands

From the motion readings the pet learns the robot's rest pose, and calls it held when it tilts away from it, turns or is shaken (held.go). While held, every head command is dropped, in one place: the robot's connection.

internal/app/robotconn.go, lines 53–59 at v0.14.1
// command queues a robot command.
func (c *robotConn) command(name string, args map[string]any) bool {
	if headCommands[name] && c.held.Load() {
		return false // the head stays still in a kid's hands
	}
	return c.count(c.Command(name, args))
}

Its pictures and sounds, kept on the robot

At connect the pet asks for the robot's file list, keeps what is there with the right checksum (CRC-32), deletes its old files and uploads the rest in chunks, paced behind the commands. Later it shows a picture by its name: a few bytes instead of a JPEG.

internal/app/assets.go, lines 59–90 at v0.14.1
	want := petAssets()
	for name, b := range want {
		if have[name] == crc32.ChecksumIEEE(b) {
			r.files[name] = true
		}
	}
	for name := range have { // the pet's old files (e.g. a picture it no longer uses)
		if _, ok := want[name]; !ok && strings.HasPrefix(name, assetDir) {
			r.conn.command("asset_delete", map[string]any{"name": name})
			a.log.Info("deleting an old pet file on the robot", "robot", r.id, "name", name)
		}
	}
	names := make([]string, 0, len(want))
	for name, b := range want {
		if crc, ok := have[name]; !ok || crc != crc32.ChecksumIEEE(b) {
			names = append(names, name)
		}
	}
	sort.Strings(names)
	if len(names) == 0 {
		a.log.Info("robot files up to date", "robot", r.id, "files", len(want))
		a.refreshFace(r)
		return
	}
	r.uploading = map[string]uint32{}
	var chunks [][]byte
	for _, name := range names {
		r.uploading[name] = crc32.ChecksumIEEE(want[name])
		chunks = append(chunks, wire.AssetChunks(name, want[name])...)
	}
	a.log.Info("uploading pet files", "robot", r.id, "files", len(names), "chunks", len(chunks))
	go a.sendChunks(r.conn, chunks)

A menu from tappable sprites

Each tile is a stored picture shown as a sprite marked tap; the robot's tap event names the sprite it hit, so the pet needs no hit test. The menu closes by itself after 15 s.

internal/app/menu.go, lines 91–103 at v0.14.1
	for _, m := range menus[name] {
		a.sprite(r, map[string]any{"id": "m:" + m.action, "asset": assetDir + m.asset, "x": m.x, "y": m.y, "z": 21, "tap": true})
	}
	id := r.menuID
	time.AfterFunc(menuTimeout, func() {
		a.mu.Lock()
		defer a.mu.Unlock()
		if r.menu != "" && r.menuID == id && r.conn != nil {
			a.closeMenu(r)
			a.express(r, a.now())
		}
	})
}
internal/app/engine.go, lines 427–432 at v0.14.1
	case "screen_tap":
		if r.menu != "" {
			sprite, _ := ev.Data["sprite"].(string)
			a.menuTap(r, sprite, now)
			return
		}

Timed steps that a newer reaction cancels

Reactions are short scenes: steps at times. Each reaction bumps a counter; a step whose counter is old is dropped, so a quick second touch never mixes two scenes.

internal/app/engine.go, lines 97–114 at v0.14.1
// later runs fn after d unless another reaction started meanwhile (or the robot left).
func (a *App) later(r *robot, d time.Duration, fn func()) {
	gen := r.gen
	time.AfterFunc(d, func() {
		a.mu.Lock()
		defer a.mu.Unlock()
		if r.gen == gen && r.conn != nil {
			fn()
		}
	})
}

// begin starts a reaction: the mood waits until it is over.
func (a *App) begin(r *robot, now time.Time, d time.Duration) {
	r.gen++
	r.busyUntil = now.Add(d)
	a.later(r, d, func() { a.express(r, a.now()) })
}

A scene: the food glides into the mouth

A sprite drops in from above the screen, glides down in 700 ms (the robot animates it), and shrinks bite by bite with a munch and a nod.

internal/app/engine.go, lines 760–778 at v0.14.1
// eatSprite: the food drops in from the top and glides to the mouth over the face,
// then shrinks bite by bite (munch, nod) and the pet says what it ate.
func (a *App) eatSprite(r *robot, asset, line string) {
	c := r.conn
	a.emotion(r, "happy")
	a.drawnFace(r, "yum")
	a.sprite(r, map[string]any{"id": "food", "asset": asset, "x": 160, "y": -60, "scale": 0.8, "z": 1})
	a.sprite(r, map[string]any{"id": "food", "x": 160, "y": 170, "ms": 700})
	a.later(r, 750*time.Millisecond, func() {
		a.play(r, "munch", false)
		c.command("nod", nil)
		a.sprite(r, map[string]any{"id": "food", "scale": 0.55})
	})
	a.later(r, 1150*time.Millisecond, func() { a.sprite(r, map[string]any{"id": "food", "scale": 0.3}) })
	a.later(r, 1500*time.Millisecond, func() {
		c.command("sprite_hide", map[string]any{"id": "food"})
		a.sayText(r, line, 3)
	})
}

A hand seen by the light sensor

During catch the ball the robot streams its light and proximity sensor 20 times a second. A hand coming close raises the proximity or darkens the light; the baselines follow the room. The stream is on only during a game.

internal/app/game.go, lines 259–284 at v0.14.1
// handNear reads one light sample: true when a hand seems close. The baselines
// follow the room: proximity drops at once and rises slowly, light follows
// while no hand is near.
func (g *game) handNear(ps, ch0 float64) bool {
	g.sawLight = true
	g.samples++
	if g.samples == 1 {
		g.psMin, g.psMax, g.ch0Min, g.ch0Max = ps, ps, ch0, ch0
	}
	g.psMin, g.psMax = min(g.psMin, ps), max(g.psMax, ps)
	g.ch0Min, g.ch0Max = min(g.ch0Min, ch0), max(g.ch0Max, ch0)
	if g.psBase < 0 {
		g.psBase, g.ch0Base = ps, ch0
		return false
	}
	near := ps >= g.psBase+proxRise || (g.ch0Base >= shadowMin && ch0 < g.ch0Base*shadowPart)
	if ps < g.psBase {
		g.psBase = ps
	} else if !near {
		g.psBase += (ps - g.psBase) * 0.02
	}
	if !near {
		g.ch0Base += (ch0 - g.ch0Base) * 0.05
	}
	return near
}

And without sprites

A robot without the ball's picture, or an older firmware, gets the same game as whole pictures.

internal/app/game.go, lines 168–178 at v0.14.1
	if ball := assetDir + "ball.png"; r.canSprite(ball) {
		// The ball glides over the face to its next spot (the center of a quarter).
		x, y := 80+160*(spot%2), 60+120*(spot/2)
		if g.round == 1 {
			a.sprite(r, map[string]any{"id": "ball", "asset": ball, "x": x, "y": y, "scale": 0.6, "z": 1})
		} else {
			a.sprite(r, map[string]any{"id": "ball", "x": x, "y": y, "ms": 300})
		}
	} else {
		a.showPicture(r, robotpic.Ball(spot))
	}

What it teaches