Skip to content

Commit 130a357

Browse files
marc0oloclaude
andauthored
chore: finalize flying_ninja migration to icp-cli (Motoko + Rust) (#1374)
* chore: finalize motoko/flying_ninja migration to icp-cli Upgrades icp.yaml to motoko@v5.0.0 and asset-canister@v2.2.1, updates mops.toml toolchain to moc 1.9.0 / core 2.5.0 with correct args format, removes dfx fallbacks from vite.config.js and actor.js, adds Makefile with tests for all public functions, adds CI workflow, rewrites README, and deletes dfx.json, BUILD.md, and .devcontainer/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: migrate rust/flying_ninja to icp-cli; bump icp-dev-env to 0.3.2 - Bump @dfinity/asset-canister v2.1.0 → v2.2.1 - Bump ic-cdk 0.16 → 0.20, candid 0.10.10 → 0.10 - Add Makefile with 4 tests - Delete dfx.json, BUILD.md - Bump both container images to icp-dev-env 0.3.2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * revert: back to icp-dev-env 0.3.1 (0.3.2 not yet on ghcr.io) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: bump icp-dev-env to 0.3.2 (images now published) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace removed ic_cdk::api::management_canister with ic_cdk::call for raw_rand ic_cdk::api::management_canister was removed in ic-cdk 0.17+. Use ic_cdk::call(Principal::management_canister(), "raw_rand", ()) instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use ic_cdk::management_canister::raw_rand() (ic-cdk 0.20 API) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use ic-cdk-management-canister crate for raw_rand ic_cdk::management_canister was removed — the canonical approach is the dedicated ic-cdk-management-canister = "0.1.1" crate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add ic-mops prerequisite Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(rust/flying_ninja): rewrite README to match Motoko style Remove dfx/BUILD.md references, add make test step, fix docs URL, clarify the Rust backend in the intro. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(flying_ninja): use refs in game loop to prevent double-scoring in dev React 18 StrictMode double-invokes effects in development. The game loop useEffect had frame-level state (ninjaY, ninjaVelocity, pipeX, gapPosition) in its dep array, causing the interval to be recreated every 30ms tick. This created a window where two intervals ran simultaneously, incrementing the score by 2 per gate instead of 1. Fix: use useRef for all mutable game state read inside the interval, reduce the dep array to [gameState, rng], and sync refs alongside state setters. Also fixes a keypress listener leak where the inline arrow function on line 103 was never removed on cleanup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(flying_ninja): use score 0 in is_high_score test to clarify intent Score 0 makes it clear that any value qualifies when the leaderboard has fewer than 10 entries — the logic is about capacity, not the score. Update description accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 25af8bc commit 130a357

21 files changed

Lines changed: 239 additions & 328 deletions

File tree

.github/workflows/flying_ninja.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: flying_ninja
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
paths:
8+
- motoko/flying_ninja/**
9+
- rust/flying_ninja/**
10+
- .github/workflows/flying_ninja.yml
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
motoko-flying_ninja:
18+
runs-on: ubuntu-24.04
19+
container: ghcr.io/dfinity/icp-dev-env-motoko:0.3.2
20+
env:
21+
ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
22+
steps:
23+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
24+
- name: Deploy and test
25+
working-directory: motoko/flying_ninja
26+
run: |
27+
icp network start -d
28+
icp deploy
29+
make test
30+
31+
rust-flying_ninja:
32+
runs-on: ubuntu-24.04
33+
container: ghcr.io/dfinity/icp-dev-env-rust:0.3.2
34+
env:
35+
ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36+
steps:
37+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
38+
- name: Deploy and test
39+
working-directory: rust/flying_ninja
40+
run: |
41+
icp network start -d
42+
icp deploy
43+
make test

motoko/flying_ninja/.devcontainer/devcontainer.json

Lines changed: 0 additions & 20 deletions
This file was deleted.

motoko/flying_ninja/BUILD.md

Lines changed: 0 additions & 26 deletions
This file was deleted.

motoko/flying_ninja/Makefile

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
.PHONY: test
2+
3+
test:
4+
@echo "=== Test 1: isHighScore returns true when leaderboard has fewer than 10 entries ==="
5+
@result=$$(icp canister call backend isHighScore '(0)') && \
6+
echo "$$result" && \
7+
echo "$$result" | grep -q 'true' && \
8+
echo "PASS" || (echo "FAIL" && exit 1)
9+
10+
@echo "=== Test 2: addLeaderboardEntry returns entry in leaderboard ==="
11+
@result=$$(icp canister call backend addLeaderboardEntry '("Alice", 100)') && \
12+
echo "$$result" && \
13+
echo "$$result" | grep -q '"Alice"' && \
14+
echo "$$result" | grep -q '100' && \
15+
echo "PASS" || (echo "FAIL" && exit 1)
16+
17+
@echo "=== Test 3: getLeaderboard returns persisted entry ==="
18+
@result=$$(icp canister call backend getLeaderboard '()') && \
19+
echo "$$result" && \
20+
echo "$$result" | grep -q '"Alice"' && \
21+
echo "$$result" | grep -q '100' && \
22+
echo "PASS" || (echo "FAIL" && exit 1)
23+
24+
@echo "=== Test 4: getRandomness returns a blob ==="
25+
@result=$$(icp canister call backend getRandomness '()') && \
26+
echo "$$result" && \
27+
echo "$$result" | grep -q 'blob' && \
28+
echo "PASS" || (echo "FAIL" && exit 1)

motoko/flying_ninja/README.md

Lines changed: 11 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,35 @@
11
# Flying Ninja
22

3-
[View this sample's code on GitHub](https://github.com/dfinity/examples/tree/master/motoko/flying_ninja)
4-
5-
## Overview
6-
7-
Flying Ninja is a 2D side-scroller game where players interact with the flying ninja character using their keyboard's space bar to move up and down. The goal is to avoid the obstacles and obtain points for each obstacle you dodge. When the game ends, the user can add their score to the leaderboard.
8-
9-
## Deploying from ICP Ninja
10-
11-
This example can be deployed directly from [ICP Ninja](https://icp.ninja), a browser-based IDE for ICP. To continue developing locally after deploying from ICP Ninja, see [BUILD.md](BUILD.md).
12-
13-
[![Open in ICP Ninja](https://icp.ninja/assets/open.svg)](https://icp.ninja/i?g=https://github.com/dfinity/examples/motoko/flying_ninja)
14-
15-
> **Note:** ICP Ninja currently uses `dfx` under the hood, which is why this example includes a `dfx.json` configuration file. `dfx` is the legacy CLI, being superseded by [icp-cli](https://cli.internetcomputer.org), which is what developers should use for local development.
3+
Flying Ninja is a 2D side-scroller game where players control a ninja character using the space bar to move up and down, dodging obstacles to earn points. When the game ends, players can submit their score to an on-chain leaderboard backed by a Motoko canister on ICP.
164

175
## Build and deploy from the command line
186

197
### Prerequisites
208

21-
- [x] Install [Node.js](https://nodejs.org/en/download/)
22-
- [x] Install [icp-cli](https://cli.internetcomputer.org): `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm`
9+
- Node.js
10+
- icp-cli: `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm`
11+
- ic-mops: `npm install -g ic-mops`
2312

2413
### Install
2514

26-
Clone the example project:
27-
2815
```bash
2916
git clone https://github.com/dfinity/examples
3017
cd examples/motoko/flying_ninja
3118
```
3219

33-
### Deployment
34-
35-
Start the local network:
20+
### Deploy and test
3621

3722
```bash
3823
icp network start -d
39-
```
40-
41-
Deploy the canisters:
42-
43-
```bash
4424
icp deploy
25+
make test
26+
icp network stop
4527
```
4628

47-
Stop the local network when done:
29+
For frontend development with hot reload:
4830

4931
```bash
50-
icp network stop
32+
npm run dev
5133
```
5234

5335
## Updating the Candid interface
@@ -57,9 +39,9 @@ The `backend/backend.did` file defines the backend canister's public interface.
5739
If you modify the backend's public API, regenerate the `.did` file:
5840

5941
```bash
60-
$(mops toolchain bin moc) --idl $(mops sources) -o backend/backend.did backend/app.mo
42+
$(mops toolchain bin moc) --idl -o backend/backend.did backend/app.mo
6143
```
6244

6345
## Security considerations and best practices
6446

65-
If you base your application on this example, it is recommended that you familiarize yourself with and adhere to the [security best practices](https://internetcomputer.org/docs/building-apps/security/overview) for developing on ICP. This example may not implement all the best practices.
47+
Refer to the [security best practices](https://docs.internetcomputer.org/guides/security/overview) for information on security and best practices for your ICP app.

motoko/flying_ninja/backend/app.mo

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Array "mo:core/Array";
22
import Nat "mo:core/Nat";
33
import Random "mo:core/Random";
44

5-
persistent actor FlyingNinja {
5+
actor FlyingNinja {
66
type Order = { #less; #equal; #greater };
77
type LeaderboardEntry = {
88
name : Text;

motoko/flying_ninja/dfx.json

Lines changed: 0 additions & 23 deletions
This file was deleted.

motoko/flying_ninja/frontend/src/Game.jsx

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useState } from 'react';
1+
import React, { useEffect, useRef, useState } from 'react';
22
import Ninja from './Ninja';
33
import Pipes from './Pipes';
44
import Score from './Score';
@@ -25,7 +25,7 @@ class SeededRNG {
2525
s1 ^= s0;
2626
s1 ^= s0 >> 26;
2727
this.state1 = s1;
28-
return (s0 + s1) / 4294967296; // This already returns a number between 0 and 1
28+
return (s0 + s1) / 4294967296;
2929
}
3030
}
3131

@@ -39,7 +39,6 @@ const Game = () => {
3939
const [rng, setRng] = useState(null);
4040
const [leaderboard, setLeaderboard] = useState([]);
4141

42-
// Add this function to fetch the leaderboard
4342
const fetchLeaderboard = async () => {
4443
try {
4544
const entries = await backend.getLeaderboard();
@@ -49,17 +48,12 @@ const Game = () => {
4948
}
5049
};
5150

52-
// this is run once when the page is loaded
5351
useEffect(() => {
5452
const initialize = async () => {
5553
try {
56-
// fetch the leaderboard
5754
await fetchLeaderboard();
58-
// initialize the seed with randomness from the internet computer
5955
const randomness = await backend.getRandomness();
6056
const seed = new Uint8Array(randomness);
61-
62-
// create a new SeededRNG with the first 8 bytes of the seed
6357
setRng(new SeededRNG(seed.slice(0, 8)));
6458
} catch (error) {
6559
console.error('Failed to initialize seed:', error);
@@ -78,12 +72,27 @@ const Game = () => {
7872
const [showNameInput, setShowNameInput] = useState(false);
7973
const [playerName, setPlayerName] = useState('');
8074

75+
// Refs hold the current values used inside the game loop interval so that
76+
// the effect does not need to re-run (and re-create the interval) on every
77+
// frame. Without refs, the dep array would include frame-level state and
78+
// React 18 StrictMode's double-invocation would register two intervals,
79+
// causing the score to increment by 2 per gate in development.
80+
const ninjaYRef = useRef(ninjaStartY);
81+
const ninjaVelocityRef = useRef(0);
82+
const pipeXRef = useRef(pipeStartX);
83+
const gapPositionRef = useRef(100);
84+
const scoreRef = useRef(0);
85+
8186
const startGame = () => {
82-
setGameState('playing');
8387
resetGame();
8488
};
8589

8690
const resetGame = () => {
91+
ninjaYRef.current = ninjaStartY;
92+
ninjaVelocityRef.current = 0;
93+
pipeXRef.current = pipeStartX;
94+
gapPositionRef.current = 100;
95+
scoreRef.current = 0;
8796
setNinjaY(ninjaStartY);
8897
setNinjaVelocity(0);
8998
setPipeX(pipeStartX);
@@ -92,20 +101,15 @@ const Game = () => {
92101
setGameState('playing');
93102
};
94103

95-
// Handle gravity and ninja movement
96104
useEffect(() => {
97105
const handleInteraction = () => {
98106
if (gameState === 'playing') {
107+
ninjaVelocityRef.current = jumpHeight;
99108
setNinjaVelocity(jumpHeight);
100109
}
101110
};
102111

103-
window.addEventListener('keypress', (event) => {
104-
if (event.key === ' ' && gameState === 'playing') {
105-
setNinjaVelocity(jumpHeight);
106-
}
107-
});
108-
112+
window.addEventListener('keypress', handleInteraction);
109113
window.addEventListener('touchstart', handleInteraction);
110114
window.addEventListener('mousedown', handleInteraction);
111115

@@ -121,24 +125,36 @@ const Game = () => {
121125

122126
if (gameState === 'playing' && rng) {
123127
gameLoop = setInterval(() => {
124-
setNinjaY((prevY) => Math.min(prevY + ninjaVelocity, window.innerHeight - 10));
125-
setNinjaVelocity((prevVelocity) => prevVelocity + gravity);
128+
// Update ninja position
129+
const newY = Math.min(ninjaYRef.current + ninjaVelocityRef.current, window.innerHeight - 10);
130+
ninjaYRef.current = newY;
131+
setNinjaY(newY);
126132

127-
setPipeX((prevX) => {
128-
if (prevX < -5) {
129-
// rng.next() already returns a number between 0 and 1
130-
setGapPosition(rng.next() * (window.innerHeight - gapHeight));
131-
setScore((prevScore) => prevScore + 1);
132-
return window.innerWidth + 5;
133-
}
134-
return prevX - 5;
135-
});
133+
const newVelocity = ninjaVelocityRef.current + gravity;
134+
ninjaVelocityRef.current = newVelocity;
135+
setNinjaVelocity(newVelocity);
136+
137+
// Update pipe position
138+
let newPipeX = pipeXRef.current - 5;
139+
if (newPipeX < -5) {
140+
const newGap = rng.next() * (window.innerHeight - gapHeight);
141+
gapPositionRef.current = newGap;
142+
setGapPosition(newGap);
143+
scoreRef.current += 1;
144+
setScore(scoreRef.current);
145+
newPipeX = window.innerWidth + 5;
146+
}
147+
pipeXRef.current = newPipeX;
148+
setPipeX(newPipeX);
136149

137150
// Collision detection
138151
if (
139-
ninjaY < 0 ||
140-
ninjaY + 30 >= window.innerHeight ||
141-
(pipeX < 130 && pipeX > 80 && (ninjaY < gapPosition || ninjaY > gapPosition + gapHeight))
152+
ninjaYRef.current < 0 ||
153+
ninjaYRef.current + 30 >= window.innerHeight ||
154+
(pipeXRef.current < 130 &&
155+
pipeXRef.current > 80 &&
156+
(ninjaYRef.current < gapPositionRef.current ||
157+
ninjaYRef.current > gapPositionRef.current + gapHeight))
142158
) {
143159
setGameState('gameOver');
144160
checkHighScore();
@@ -147,10 +163,10 @@ const Game = () => {
147163
}
148164

149165
return () => clearInterval(gameLoop);
150-
}, [ninjaY, ninjaVelocity, pipeX, gapPosition, gameState, rng]);
166+
}, [gameState, rng]);
151167

152168
const checkHighScore = async () => {
153-
const isHighScore = await backend.isHighScore(BigInt(score));
169+
const isHighScore = await backend.isHighScore(BigInt(scoreRef.current));
154170
console.log('isHighScore', isHighScore);
155171
if (isHighScore) {
156172
setShowNameInput(true);
@@ -159,10 +175,10 @@ const Game = () => {
159175

160176
const submitScore = async () => {
161177
if (playerName.trim() !== '') {
162-
await backend.addLeaderboardEntry(playerName, BigInt(score));
178+
await backend.addLeaderboardEntry(playerName, BigInt(scoreRef.current));
163179
setShowNameInput(false);
164180
setGameState('gameOver');
165-
await fetchLeaderboard(); // update the leaderboard
181+
await fetchLeaderboard();
166182
}
167183
};
168184

0 commit comments

Comments
 (0)