Eight directions
A menu, a robot or a game usually wants a direction, not two numbers. Measure how far the stick is from the centre, and if it is past the dead zone, cut the circle into eight 45° slices and name the one it is in. A round dead zone keeps a slight diagonal from snapping onto an axis.
From two numbers to one word
The last sketch turns the stick into two numbers from −100 to 100. Most projects then ask a simpler question: which way is it pushed? Two steps answer it. First, is the stick far enough from the centre to count? Second, if it is, which direction is it nearest to?
Move the stick round with the two sliders and watch which slice lights. Each slice is centred on its direction and 45° wide, so there is 22.5° of slack either side. Now switch the dead zone to square and push gently just off a diagonal: the two answers part company.
A round dead zone
The last article's dead zone works on each axis alone: anything under 10 on
X is 0 on X. Drawn on the circle, that is a cross of two bands, and near the
middle a slight diagonal loses its smaller half and snaps onto an axis. For
a direction, the dead zone should be a distance instead: the stick counts
once x * x + y * y is at least DEAD_ZONE * DEAD_ZONE, whichever way it
points. Comparing squares saves a square root and gives the same answer.
Which slice
atan2(y, x) gives the stick's angle from the X axis, from −180° to 180°
in radians. Dividing by 45° and rounding gives a whole number from −4 to 4,
and adding 8 and taking the remainder turns it into 0 to 7, counting round
from right. NAME[] is the same list in that order.
For four directions, divide by 90° instead and use every second name.
Which way is up
The sketch calls positive Y up and positive X right, and your stick may
disagree. From the first
read you know which
number moved when you pushed towards the header: if it was X, as the
drawing suggests, set SWAP_XY to true. Then push each way and flip
X_SIGN or Y_SIGN for any direction that comes out backwards.
What you should see
Push the stick round a full circle and let go, then click:
right
up-right
up
up-left
left
down-left
down
down-right
centre
clickEach name prints once, when it changes, and the click once per press.
The code
The stick as one of eight directions or centre, printed only when it changes, and a click printed once per press. The mapping is the last article's; the dead zone is now a distance from the centre.
/*
Dual Axis Joystick - eight directions TK23 / /p/tk23
Wiring. Parts up, header along the bottom. Count from the square pad,
which is GND at the left-hand end, rightwards:
GND -> GND
VCC -> 5V on an Uno; 3V3 on an ESP32, ESP32-S3 or Pico
NC -> nothing (in no net on the board)
X -> A0 on an Uno, GPIO 34 on an ESP32, GPIO 4 on an
ESP32-S3, GP26 on a Raspberry Pi Pico (analog)
Y -> A1, GPIO 35, GPIO 5, GP27 (same order, analog)
SW -> D2, GPIO 25, GPIO 7, GP15 (digital)
Arduino IDE
Tools > Board your board, e.g. ESP32S3 Dev Module
Tools > Port the one that appears when you plug in
Tools > USB CDC On Boot Enabled (ESP32-S3 only)
No library needed.
*/
// The pins X, Y and SW are wired to.
// Uno: A0 A1 2. ESP32: 34 35 25. ESP32-S3: 4 5 7. Pico: 26 27 15.
const int X_PIN = 4;
const int Y_PIN = 5;
const int SW_PIN = 7;
#if defined(ARDUINO_ARCH_ESP32)
const float ADC_MAX = 4095;
#else
const float ADC_MAX = 1023;
#endif
const int DEAD_ZONE = 10; // as a distance from the centre
const int EDGE_GUESS = 30; // per cent of full scale, until learned
const unsigned long DEBOUNCE_MS = 20;
// From the first read: SWAP_XY true if X moved when you pushed towards
// and away from the header; a sign -1 if that direction came out wrong.
const bool SWAP_XY = false;
const int X_SIGN = 1;
const int Y_SIGN = 1;
const char* NAME[] = {"right", "up-right", "up", "up-left",
"left", "down-left", "down", "down-right"};
float centreX, loX, hiX, centreY, loY, hiY;
int lastDir = -2; // -1 is centre; -2 is not yet known
int lastSw = LOW;
unsigned long quietUntil = 0;
float average(int pin) {
float sum = 0;
for (int i = 0; i < 16; i++) sum += analogRead(pin);
return sum / 16;
}
int axis(float v, float centre, float &lo, float &hi) {
if (v < lo) lo = v;
if (v > hi) hi = v;
float p;
if (v >= centre) p = (v - centre) * 100.0 / (hi - centre);
else p = (v - centre) * 100.0 / (centre - lo);
return constrain((int)p, -100, 100);
}
// 0..7 for the eight slices, counting round from right; -1 for centre.
int direction(int x, int y) {
if (x * x + y * y < DEAD_ZONE * DEAD_ZONE) return -1;
float a = atan2((float)y, (float)x); // -pi..pi
int i = (int)round(a / (PI / 4));
return (i + 8) % 8;
}
void setup() {
Serial.begin(115200);
pinMode(SW_PIN, INPUT); // R10 on the board pulls it down
delay(200); // hands off the stick
float guess = ADC_MAX * EDGE_GUESS / 100.0;
centreX = average(X_PIN);
centreY = average(Y_PIN);
loX = centreX - guess;
hiX = centreX + guess;
loY = centreY - guess;
hiY = centreY + guess;
}
void loop() {
int a = axis(average(X_PIN), centreX, loX, hiX);
int b = axis(average(Y_PIN), centreY, loY, hiY);
int x = X_SIGN * (SWAP_XY ? b : a);
int y = Y_SIGN * (SWAP_XY ? a : b);
int d = direction(x, y);
if (d != lastDir) {
Serial.println(d < 0 ? "centre" : NAME[d]);
lastDir = d;
}
unsigned long now = millis();
if (now >= quietUntil) {
int sw = digitalRead(SW_PIN);
if (sw != lastSw) {
if (sw == HIGH) Serial.println("click");
lastSw = sw;
quietUntil = now + DEBOUNCE_MS;
}
}
delay(10);
}After the first read, set SWAP_XY, X_SIGN and Y_SIGN so that pushing away from you prints up. The click uses the millis() wait from the last article, so the loop keeps reading the stick while SW settles.
The same in MicroPython, for an ESP32, an ESP32-S3 or a Pico: eight directions or centre, printed when they change, and one click per press.
"""
Dual Axis Joystick - eight directions, MicroPython
Wiring. Parts up, header along the bottom. Count from the square pad,
which is GND at the left-hand end, rightwards:
GND -> GND
VCC -> 3V3 (never 5V: X, Y and SW all reach VCC)
NC -> nothing (in no net on the board)
X -> GPIO 34 on an ESP32, GPIO 4 on an ESP32-S3,
GP26 on a Raspberry Pi Pico (analog)
Y -> GPIO 35, GPIO 5, GP27 (same order, analog)
SW -> GPIO 25, GPIO 7, GP15 (digital)
Thonny
Run > Configure interpreter MicroPython (ESP32) or
MicroPython (Raspberry Pi Pico)
Save it to the board as main.py to run it on every power-up.
Nothing to install: machine, math, sys and time are built in.
"""
import math
import sys
import time
from machine import ADC, Pin
# The GPIO numbers X, Y and SW are wired to.
# ESP32: 34 35 25. ESP32-S3: 4 5 7. Pico: 26 27 15.
X_PIN, Y_PIN, SW_PIN = 4, 5, 7
FULL = 65535 # read_u16 on every board
DEAD_ZONE = 10 # as a distance from the centre
EDGE_GUESS = 30 # per cent of full scale, until learned
DEBOUNCE_MS = 20
# From the first read: see the Arduino sketch's comment.
SWAP_XY = False
X_SIGN, Y_SIGN = 1, 1
NAME = ("right", "up-right", "up", "up-left",
"left", "down-left", "down", "down-right")
ESP = sys.platform == "esp32" # ESP32 and ESP32-S3
def analog(pin):
adc = ADC(Pin(pin))
if ESP:
adc.atten(ADC.ATTN_11DB) # the full range, to about 3.1 V
return adc
def average(adc):
return sum(adc.read_u16() for _ in range(16)) / 16
class Axis:
def __init__(self, pin):
self.adc = analog(pin)
self.centre = average(self.adc) # hands off the stick
guess = FULL * EDGE_GUESS / 100
self.lo = self.centre - guess
self.hi = self.centre + guess
def read(self):
v = average(self.adc)
self.lo = min(self.lo, v)
self.hi = max(self.hi, v)
if v >= self.centre:
p = (v - self.centre) * 100 / (self.hi - self.centre)
else:
p = (v - self.centre) * 100 / (self.centre - self.lo)
return max(-100, min(100, int(p)))
def direction(x, y):
if x * x + y * y < DEAD_ZONE * DEAD_ZONE:
return -1
i = round(math.atan2(y, x) / (math.pi / 4))
return i % 8
sw = Pin(SW_PIN, Pin.IN) # R10 on the board pulls it down
time.sleep_ms(200)
x_axis = Axis(X_PIN)
y_axis = Axis(Y_PIN)
last_dir, last_sw, quiet_from = -2, 0, time.ticks_ms()
while True:
a, b = x_axis.read(), y_axis.read()
x = X_SIGN * (b if SWAP_XY else a)
y = Y_SIGN * (a if SWAP_XY else b)
d = direction(x, y)
if d != last_dir:
print("centre" if d < 0 else NAME[d])
last_dir = d
now = time.ticks_ms()
if time.ticks_diff(now, quiet_from) >= DEBOUNCE_MS:
s = sw.value()
if s != last_sw:
if s:
print("click")
last_sw = s
quiet_from = now
time.sleep_ms(10)There is no Uno here: an Uno cannot run MicroPython. time.ticks_ms and ticks_diff do the waiting, so the loop keeps reading the stick while SW settles. Set SWAP_XY and the signs after the first read. Stop it with Ctrl-C.
When it does not work
X and Y are the pots' names, not directions, and on this board X probably follows the tilt towards and away from the header. Set SWAP_XY to true. If a direction then comes out backwards, set X_SIGN or Y_SIGN to -1.
The stick is sitting on the line between two slices, where a jitter of one count changes the answer. Hold it a little further in, or print a new direction only once it has held for two readings in a row.
Eight slices are 45° each, so a diagonal is as wide as a straight direction, but a thumb tends to push along the axes. If your project only needs four, use four slices of 90°: the figure shows the difference.
The switch chattered for longer than DEBOUNCE_MS. Raise it to 30 or 50 ms; a person cannot click fast enough for that to matter.
Six symptoms, and the light or the wire to check first for each.
When a reading is wrong →Edit this page — content/books/dual-axis-joystick/eight-directions.mdx
Questions about this product
See what other owners have asked, and read their solutions.
Dual Axis Joystick
Loading discussions…
Discuss this article
Ask about this page. The answer stays here, on the page it belongs to, for whoever hits the same wall next.