feat(deck-slice): Fruit-Ninja-style slice game driven by deck-pose (phase 3) #908

Closed
lytedev wants to merge 1 commit from deck-slice into deck-pose-events
Owner

The first real consumer of the pose daemon's socket — and as much a test that the protocol is usable as it is a game.

Stacked on #907, which is stacked on #906. Base is deck-pose-events. Merge order: #906#907 → this.

Packaged as pkgs.deck-slice, not enabled on any host, nothing deployed.

What it is

Targets are flung up from the bottom; you slice them by swiping a wrist through them. Up to six players, each with their own blade colour and score. Bombs cost 10 points and are distinguished by shape as well as colour, so a colour-blind player is not the one who finds out by losing. Six GDScript files, no binary assets — it is all reviewable in the diff.

No daemon changes were needed

Godot 4.6 ships StreamPeerUDS, so the game reads the Unix socket directly. I checked before assuming: I had been ready to add a TCP transport to #907 for this, and it turned out to be unnecessary. Verified against the actual nixpkgs binary (ClassDB.class_exists("StreamPeerUDS") == true on 4.6.3).

One gotcha that cost real time and now carries a comment: FileAccess.file_exists returns false for a Unix socket — it is not a regular file — so the "is the daemon up yet" check goes through DirAccess instead. Without that the game silently never connects.

Two decisions that shape how it feels

Slicing is driven by the pose stream, not by swipe events. A swipe event is one discrete notification per motion — it has a refractory period by design. But one swing can pass through three targets and the player expects all three to fall. So every blade's swept segment is tested against every target every frame. Swipe events drive only the "you swung" feedback, which should fire even on a miss.

Hits test the segment the hand travelled, not where it is now. At 60fps a fast swipe moves a hand hundreds of pixels between frames; a point-in-circle test tunnels straight through and the player would swear they hit it.

The view is mirrored. Not cosmetic — without it the game is unplayable in a way people struggle to articulate.

Defensive about the daemon

Polls for the socket, connects whenever it appears, survives a daemon restart, tolerates partial lines and malformed JSON, and caps its read buffer. While disconnected it says so on screen and prints the command to start the daemon — a pose game with no daemon looks identical to one that cannot see you, and "nothing is moving" is not a diagnostic.

Packaging

Project directory plus a wrapper running godot4 --path, not an exported binary: export would pull in godotPackages.export-templates-bin (hundreds of MB of prebuilt engine binaries) to produce a .pck of the same GDScript already in the tree.

The build runs godot4 --headless --import twice, and that is load-bearing rather than cargo-culted — Godot writes its import cache into .godot/ inside the project, and a /nix/store path is read-only at runtime. I verified that a read-only project fails without it. Two passes because the first registers the scripts and the second resolves the scene's references to them.

Verified

nix build .#deck-slice succeeds, and the packaged binary was run end to end against a synthetic pose stream (a wrist swept across the frame at a known speed, emitting the real NDJSON):

deck-slice: connected to /tmp/claude-1000/fake-pose2.sock
deck-slice: P1 sliced target, score 1
...
deck-slice: P1 sliced BOMB, score -6
WARNING: pose daemon disconnected: daemon closed the connection

So: connects, parses, tracks blades, slices, applies the bomb penalty, and handles the daemon vanishing.

Untested — please read this part

  • No real camera has driven it. Daniel's browser held /dev/video0 for the whole session and I was not going to kill it. The daemon side is validated live (#906/#907); the join between daemon and game has only been exercised against the synthetic stream.
  • Nothing has run on the steamdeck, under gamescope or otherwise.
  • Only one player. The multi-player paths are written but two people have never stood in front of it.
  • Cut threshold and spawn rate are guesses, tuned against a synthetic sweep rather than a person. Expect to adjust them the first time anyone plays.

Gamescope

Not fought, as instructed — but written down rather than waved at. lib/doc/deck-slice.md has a section listing what Game Mode will actually need: the daemon as a user service started before the session, camera access inside that session, whether a Steam-launched game shares $XDG_RUNTIME_DIR with a user service, adding it to Steam as a non-Steam game, and how the daemon and game split four cores.

The first real consumer of the pose daemon's socket — and as much a test that the protocol is usable as it is a game. **Stacked on #907, which is stacked on #906.** Base is `deck-pose-events`. Merge order: #906 → #907 → this. Packaged as `pkgs.deck-slice`, **not enabled on any host**, nothing deployed. ## What it is Targets are flung up from the bottom; you slice them by swiping a wrist through them. Up to six players, each with their own blade colour and score. Bombs cost 10 points and are distinguished by shape as well as colour, so a colour-blind player is not the one who finds out by losing. Six GDScript files, no binary assets — it is all reviewable in the diff. ## No daemon changes were needed Godot 4.6 ships `StreamPeerUDS`, so the game reads the Unix socket directly. I checked before assuming: I had been ready to add a TCP transport to #907 for this, and it turned out to be unnecessary. Verified against the actual nixpkgs binary (`ClassDB.class_exists("StreamPeerUDS") == true` on 4.6.3). One gotcha that cost real time and now carries a comment: **`FileAccess.file_exists` returns `false` for a Unix socket** — it is not a regular file — so the "is the daemon up yet" check goes through `DirAccess` instead. Without that the game silently never connects. ## Two decisions that shape how it feels **Slicing is driven by the pose stream, not by `swipe` events.** A swipe event is one discrete notification per motion — it has a refractory period by design. But one swing can pass through three targets and the player expects all three to fall. So every blade's swept segment is tested against every target every frame. Swipe events drive only the "you swung" feedback, which should fire even on a miss. **Hits test the segment the hand travelled, not where it is now.** At 60fps a fast swipe moves a hand hundreds of pixels between frames; a point-in-circle test tunnels straight through and the player would swear they hit it. The view is mirrored. Not cosmetic — without it the game is unplayable in a way people struggle to articulate. ## Defensive about the daemon Polls for the socket, connects whenever it appears, survives a daemon restart, tolerates partial lines and malformed JSON, and caps its read buffer. While disconnected it says so on screen **and prints the command to start the daemon** — a pose game with no daemon looks identical to one that cannot see you, and "nothing is moving" is not a diagnostic. ## Packaging Project directory plus a wrapper running `godot4 --path`, not an exported binary: export would pull in `godotPackages.export-templates-bin` (hundreds of MB of prebuilt engine binaries) to produce a `.pck` of the same GDScript already in the tree. The build runs `godot4 --headless --import` twice, and that is load-bearing rather than cargo-culted — Godot writes its import cache into `.godot/` inside the project, and a `/nix/store` path is read-only at runtime. I verified that a read-only project **fails** without it. Two passes because the first registers the scripts and the second resolves the scene's references to them. ## Verified `nix build .#deck-slice` succeeds, and the **packaged** binary was run end to end against a synthetic pose stream (a wrist swept across the frame at a known speed, emitting the real NDJSON): ``` deck-slice: connected to /tmp/claude-1000/fake-pose2.sock deck-slice: P1 sliced target, score 1 ... deck-slice: P1 sliced BOMB, score -6 WARNING: pose daemon disconnected: daemon closed the connection ``` So: connects, parses, tracks blades, slices, applies the bomb penalty, and handles the daemon vanishing. ## Untested — please read this part - **No real camera has driven it.** Daniel's browser held `/dev/video0` for the whole session and I was not going to kill it. The daemon side is validated live (#906/#907); the *join* between daemon and game has only been exercised against the synthetic stream. - **Nothing has run on the steamdeck**, under gamescope or otherwise. - **Only one player.** The multi-player paths are written but two people have never stood in front of it. - **Cut threshold and spawn rate are guesses**, tuned against a synthetic sweep rather than a person. Expect to adjust them the first time anyone plays. ## Gamescope Not fought, as instructed — but written down rather than waved at. `lib/doc/deck-slice.md` has a section listing what Game Mode will actually need: the daemon as a user service started before the session, camera access inside that session, whether a Steam-launched game shares `$XDG_RUNTIME_DIR` with a user service, adding it to Steam as a non-Steam game, and how the daemon and game split four cores.
feat(deck-slice): Fruit-Ninja-style slice game driven by deck-pose
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m15s
76b274b40f
The first real consumer of the pose daemon`s socket, and as much a test that
the protocol is usable as it is a game. Targets are flung up; you slice them by
swiping a wrist through them. Up to six players, each with their own blade
colour and score.

Godot 4.6 has StreamPeerUDS, so the game reads the Unix socket directly. No
bridge process, and no reason for the daemon to grow a TCP transport just to be
playable. (Gotcha worth the comment it carries: FileAccess.file_exists returns
FALSE for a socket — it is not a regular file — so the "is the daemon up yet"
check goes through DirAccess instead.)

Two decisions shape how it feels:

* Slicing is driven by the POSE STREAM, not by swipe events. A swipe event is
  one discrete notification per motion by design, but one swing can pass
  through three targets and the player expects all three to fall. So every
  blade`s swept segment is tested against every target every frame; swipe
  events only drive "you swung" feedback, which should fire even on a miss.

* Hits test the SEGMENT the hand travelled, not where it is now. At 60fps a
  fast swipe moves a hand hundreds of pixels between frames, so a
  point-in-circle test tunnels straight through and the player would swear they
  hit it.

The view is mirrored, which is not cosmetic: without it the game is unplayable
in a way people struggle to articulate.

The game is defensive about the daemon`s lifetime — it polls for the socket,
connects whenever it appears, survives a restart, and says on screen that it is
waiting and how to start the daemon. A pose game with no daemon looks identical
to one that cannot see you, and "nothing is moving" is not a diagnostic.

Packaged as the project directory plus a wrapper rather than an exported
binary: export would pull in hundreds of MB of engine templates to produce a
.pck of the same six GDScript files. The build does run --import twice, and
that is load-bearing — Godot writes its cache into .godot/ inside the project,
which a store path cannot allow at runtime.

Validated end to end against a synthetic pose stream (a wrist swept at a known
speed): connects, tracks blades, slices, applies the bomb penalty, and
reconnects when the daemon exits. Not driven by a real camera yet — the
development machine`s camera was in use — and never run on the steamdeck.
lib/doc/deck-slice.md records what Game Mode will need.
lytedev force-pushed deck-slice from 76b274b40f
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m15s
to 5b0b8ff52b
Some checks failed
/ check-format (push) Successful in 9s
/ build (push) Has been cancelled
2026-08-03 10:57:44 -05:00
Compare
lytedev force-pushed deck-slice from 5b0b8ff52b
Some checks failed
/ check-format (push) Successful in 9s
/ build (push) Has been cancelled
to 8232c55017
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m30s
2026-08-03 11:01:57 -05:00
Compare
Author
Owner

Superseded by #915, which consolidates the whole markerless-pose-input program into a single WIP branch against main. Every commit from this PR is preserved there — nothing was squashed, so the root-cause writeups in the commit messages are intact. Closing here; review happens on #915.

Superseded by #915, which consolidates the whole markerless-pose-input program into a single WIP branch against `main`. Every commit from this PR is preserved there — nothing was squashed, so the root-cause writeups in the commit messages are intact. Closing here; review happens on #915.
lytedev closed this pull request 2026-08-03 11:30:08 -05:00
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m30s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lytedev/nix!908
No description provided.